Case Study: Île-de-France Public Transit
This case study, extending the scenario introduced in
[Senellart et al., 2018], applies ProvSQL to the
real-world GTFS dataset for Île-de-France public transit. It demonstrates
Boolean provenance at scale for wheelchair accessibility reasoning, then
journeys with changes as a WITH RECURSIVE query over the whole network,
whose rows the same query evaluates in several semirings: shortest travel
time, accessibility, the equations of the recursion and the journeys they
describe, the fastest journey itself, step by step, which of the metro and
the train is faster, and the probability of reaching a station when parts
of the network are disrupted.
Note
Unlike the other case studies, this one is not available in the ProvSQL Playground: it loads the large Île-de-France GTFS dataset, which is not bundled and must be fetched separately (see the Playground note). Run it against a local ProvSQL installation.
The Scenario
Île-de-France is the region around Paris. Its network combines the
metro, inside Paris; the RER, express trains crossing the city and
running out to the suburbs (lines A to E); suburban trains; trams; and
buses, including the night buses (Noctilien, lines numbered N…).
The STIF GTFS dataset describes hundreds of transit routes and tens of thousands of stops, in GTFS, the General Transit Feed Specification: a set of CSV files whose columns the GTFS reference documents.
Starting from Luxembourg station, in the centre of Paris (served by RER B and several bus lines), you want to know:
which stops and lines are reachable without changing, and whether the journey is fully wheelchair-accessible: Boolean provenance answers this, a result token evaluating to
trueif and only if every record along the trip has the wheelchair flag set;which stations are reachable at all, changing as often as needed, how long it takes, whether some journey is accessible, and how likely the journey remains when lines are disrupted: a recursive query over the network answers all four.
Warning
This case study requires external data files. The dataset is not bundled with ProvSQL due to its size (the compressed download is several hundred megabytes). Download instructions are in Setup below.
Setup
Download the GTFS data. Obtain the Île-de-France GTFS archive from
data.gouv.fr
(direct download: IDFM-gtfs.zip).
Extract the archive; you will need the four text files
routes.txt, stops.txt, trips.txt, and stop_times.txt.
Load the schema and data. Download
setup.sql
and run it from the directory containing the four GTFS files:
cd /path/to/gtfs-files
psql -d mydb -f /path/to/setup.sql
Loading the files, millions of rows of timetable among them, takes a couple of minutes. This creates four tables:
routes– transit lines (RER A, B, M1, bus 91…)stops– individual stop points with GPS coordinates and awheelchair_boardingflagtrips– individual scheduled journeys, each with awheelchair_accessibleflagstop_times– arrival and departure times at each stop for each trip
The script also adds provenance tracking to trips and stops, and
normalises their wheelchair columns to 1 (accessible) or 0.
It also calls setup_search_path, which adds provsql to the
database’s search_path: every new session calls ProvSQL’s functions
without the provsql. prefix.
Note
The setup script already creates the most important indexes
(on stop_id, trip_id, and parent station), which are
essential for acceptable performance on this large dataset.
Step 1: Explore the Database
Inspect the four tables:
SELECT COUNT(*) FROM routes;
SELECT COUNT(*) FROM stops;
SELECT COUNT(*) FROM trips;
SELECT COUNT(*) FROM stop_times;
The stops table has two kinds of rows, told apart by
location_type. A stop (location_type 0) is where a vehicle
calls: a platform, or a bus stop; it is what stop_times refers to. A
station (location_type 1) groups the stops of one place: each of
its stops names it in its parent_station column. Luxembourg station
thus groups its RER platforms and the bus stops around it. To find it and
its stops:
SELECT * FROM stops WHERE stop_name = 'Luxembourg';
Two stations carry the name: IDFM:71161, Luxembourg in Paris, and
IDFM:65716, a namesake elsewhere in the region. The queries below
start from the first, and reach its trips through its stops.
Step 2: Provenance and Wheelchair Mapping
Provenance has already been added to trips and stops by
setup.sql: every row of a query over them carries the provenance of the
trip and stop records it reads. Their wheelchair columns become the values
of a provenance mapping, which associates each input token with a value.
Each call below builds a mapping for one table; their union covers the
tokens of both, and an index speeds up the lookups in this large mapping:
DROP TABLE IF EXISTS w_trips, w_stops, wheelchair;
SELECT create_provenance_mapping('w_trips', 'trips', 'wheelchair_accessible');
SELECT create_provenance_mapping('w_stops', 'stops', 'wheelchair_boarding');
CREATE TABLE wheelchair AS
SELECT * FROM w_trips UNION SELECT * FROM w_stops;
CREATE INDEX ON wheelchair(provenance);
A result token evaluates to true (1) under sr_boolean if
every contributing row has its wheelchair column set to 1. Inspect the
mapping:
SELECT * FROM wheelchair LIMIT 10;
Step 3: Reachable Stops from Luxembourg
Find all stops reachable from Luxembourg on the same trip and later in the sequence – in other words, stops you can reach by boarding a vehicle at Luxembourg without changing:
SELECT DISTINCT s2.stop_name, r2.route_long_name
FROM stops s0
JOIN stops s1 ON s1.parent_station = s0.stop_id
JOIN stop_times t1 ON s1.stop_id = t1.stop_id
JOIN stop_times t2 ON t1.trip_id = t2.trip_id
AND t1.stop_sequence < t2.stop_sequence
JOIN stops s2 ON s2.stop_id = t2.stop_id
JOIN trips u2 ON u2.trip_id = t2.trip_id
JOIN routes r2 ON r2.route_id = u2.route_id
WHERE s0.stop_id = 'IDFM:71161'
ORDER BY r2.route_long_name, s2.stop_name;
This returns every (stop, line) pair reachable without changing, on RER B and on the bus lines through the station.
Step 4: Boolean Provenance – Full Wheelchair Accessibility
Add Boolean provenance evaluation to mark which results are fully wheelchair-accessible along every leg. Because the query returns one row per trip (each with its own provenance circuit), materialise the result first and then aggregate per destination:
CREATE TEMP TABLE from_luxembourg AS
SELECT s2.stop_name,
r2.route_long_name,
sr_boolean(provenance(), 'wheelchair') AS accessible
FROM stops s0
JOIN stops s1 ON s1.parent_station = s0.stop_id
JOIN stop_times t1 ON s1.stop_id = t1.stop_id
JOIN stop_times t2 ON t1.trip_id = t2.trip_id
AND t1.stop_sequence < t2.stop_sequence
JOIN stops s2 ON s2.stop_id = t2.stop_id
JOIN trips u2 ON u2.trip_id = t2.trip_id
JOIN routes r2 ON r2.route_id = u2.route_id
WHERE s0.stop_id = 'IDFM:71161';
SELECT remove_provenance('from_luxembourg');
SELECT stop_name, route_long_name, bool_or(accessible) AS accessible
FROM from_luxembourg
GROUP BY stop_name, route_long_name
ORDER BY route_long_name, stop_name;
sr_boolean evaluates the provenance token of each row under
the Boolean semiring, looking up each leaf token in the wheelchair
table: true when every record the row reads (the station, its platform,
the stop, the trip) has the wheelchair flag set, i.e., when that trip
makes that journey accessibly. The table holds a row per trip and stop,
hundreds of thousands of them, so this step takes a little while.
The materialised table still carries the provsql provenance column,
so remove_provenance drops tracking first, and the final
aggregation runs outside ProvSQL. bool_or asks whether some trip
of the line reaches the stop accessibly from Luxembourg: one can wait for
an accessible vehicle. bool_and would ask that every trip does; on
this network, the two rarely differ.
Step 5: Inspect Individual Results with sr_formula
For a stop that is not fully accessible, use sr_formula to
identify which specific trip or stop is responsible. Here we inspect
the Musée du Louvre stop on bus line 27 as an example:
SELECT s2.stop_name,
sr_formula(provenance(), 'wheelchair') AS formula
FROM stops s0
JOIN stops s1 ON s1.parent_station = s0.stop_id
JOIN stop_times t1 ON s1.stop_id = t1.stop_id
JOIN stop_times t2 ON t1.trip_id = t2.trip_id
AND t1.stop_sequence < t2.stop_sequence
JOIN stops s2 ON s2.stop_id = t2.stop_id
JOIN trips u2 ON u2.trip_id = t2.trip_id
JOIN routes r2 ON r2.route_id = u2.route_id
WHERE s0.stop_id = 'IDFM:71161'
AND r2.route_long_name = '27'
AND s2.stop_name = 'Musée du Louvre'
LIMIT plain(1);
The formula shows which token carries a 0 wheelchair value,
pinpointing the accessibility barrier:
stop_name | formula
----------------+---------------
Musée du Louvre | 1 ⊗ 1 ⊗ 0 ⊗ 1
The four factors correspond to the four provenance-enabled table
instances in the join: the Luxembourg station record (stops, 1), its
platform record (stops, 1), the Musée du Louvre stop record
(stops, 0), and the trip record (trips, 1). The 0 on the third
factor pinpoints the specific Musée du Louvre stop served by line 27 as
the accessibility barrier. Several stops are named Musée du Louvre in
the dataset; the one served by line 27 has wheelchair_boarding = 0, as
we can verify:
SELECT DISTINCT s2.stop_id, s2.wheelchair_boarding
FROM stops s0
JOIN stops s1 ON s1.parent_station = s0.stop_id
JOIN stop_times t1 ON s1.stop_id = t1.stop_id
JOIN stop_times t2 ON t1.trip_id = t2.trip_id
AND t1.stop_sequence < t2.stop_sequence
JOIN stops s2 ON s2.stop_id = t2.stop_id
JOIN trips u2 ON u2.trip_id = t2.trip_id
JOIN routes r2 ON r2.route_id = u2.route_id
WHERE s0.stop_id = 'IDFM:71161'
AND r2.route_long_name = '27'
AND s2.stop_name = 'Musée du Louvre';
Step 6: The Next Stop on Each Line (LATERAL)
Step 3 listed every reachable stop. A LATERAL subquery asks a more
focused question – for each line through Luxembourg, what is the very
next stop after it on that trip:
SELECT DISTINCT r.route_long_name,
nxt.stop_name AS next_stop,
sr_boolean(provenance(), 'wheelchair') AS accessible
FROM stops s0
JOIN stops s1 ON s1.parent_station = s0.stop_id
JOIN stop_times t1 ON s1.stop_id = t1.stop_id
JOIN trips u ON u.trip_id = t1.trip_id
JOIN routes r ON r.route_id = u.route_id
JOIN LATERAL (
SELECT s2.stop_name
FROM stop_times t2
JOIN stops s2 ON s2.stop_id = t2.stop_id
WHERE t2.trip_id = t1.trip_id
AND t2.stop_sequence > t1.stop_sequence
ORDER BY t2.stop_sequence
LIMIT plain(1)
) nxt ON true
WHERE s0.stop_id = 'IDFM:71161'
ORDER BY r.route_long_name, nxt.stop_name;
The LATERAL subquery runs once per outer row and may reference its
columns (t1.trip_id, t1.stop_sequence); the ORDER BY … LIMIT
plain(1) keeps only the immediately following stop. stops and
trips are provenance-tracked (the untracked stop_times and
routes contribute certain provenance, as in an ordinary join), so
each (line, next stop) row carries the lineage of the records that
produced it, and sr_boolean reports whether the hop to the
next stop is wheelchair-accessible.
Without plain, ORDER BY … LIMIT 1 would be read in every
possible world: each later stop would be a candidate, conditioned on no
earlier stop being present. Here a token stands for an accessible record,
so this would ask for the next accessible stop, another question, and a
costly one: each row would carry a condition over all the later stops of
its trip, for every trip through Luxembourg, and the query does not finish
in reasonable time. Every stop exists, so which stop comes next is a plain
fact of the timetable: LIMIT plain(1) keeps the next stop of the
actual data, with the provenance its row has in the full result.
ProvSQL emits a WARNING about this LIMIT in a subquery, since
that provenance does not say that the stop is the next one; here, the
answer means what it says. See ORDER BY, LIMIT and OFFSET for the general rule.
Step 7: The Network as a Table
Steps 3 to 6 stayed on one vehicle. To change lines, describe the network
itself: one row per pair of consecutive stations on a line, with the
average scheduled time between them and whether some trip makes that hop
accessibly. This is plain SQL over the timetable, a long computation that
needs no provenance: turn the rewriting off with provsql.active while
it runs, rather than tracking the provenance of millions of rows for
nothing.
SET provsql.active = off;
DROP TABLE IF EXISTS hop_minutes, hop_access, hop_name, hop_one, hop;
DROP VIEW IF EXISTS trip_hops, calls;
First, every call of a trip at a stop, with its line, the station of the stop, its arrival time in seconds since midnight, and whether both the trip and the stop are accessible:
CREATE TEMP VIEW calls AS
SELECT st.trip_id, st.stop_sequence, r.route_short_name AS line,
r.route_type,
coalesce(nullif(s.parent_station, ''), s.stop_id) AS station,
split_part(st.arrival_time, ':', 1)::int * 3600
+ split_part(st.arrival_time, ':', 2)::int * 60
+ split_part(st.arrival_time, ':', 3)::int AS arrival,
t.wheelchair_accessible = 1 AND s.wheelchair_boarding = 1
AS accessible
FROM stop_times st
JOIN trips t ON t.trip_id = st.trip_id
JOIN routes r ON r.route_id = t.route_id
JOIN stops s ON s.stop_id = st.stop_id;
Then every hop of a trip, a call and the next call of the same trip, which
the window function lead reads in the order of the trip. Its time runs
from arrival to arrival, so that it counts the stop at the station, still
on board; the hop is accessible when both its calls are:
CREATE TEMP VIEW trip_hops AS
SELECT line, route_type, station AS from_station,
lead(station) OVER w AS to_station,
(lead(arrival) OVER w - arrival) / 60.0 AS minutes,
accessible AND lead(accessible) OVER w AS accessible
FROM calls
WINDOW w AS (PARTITION BY trip_id ORDER BY stop_sequence);
Finally, one row per hop of a line, over all its trips: the average time, and whether some trip makes it accessibly. Bus schedules give times to the minute, so a single trip often shows a hop as 0 or 1 minute; the average evens this rounding out. The last call of a trip has no next one, and a hop with no scheduled time is left out:
CREATE TABLE hop AS
SELECT from_station, to_station, line, route_type,
round(avg(minutes), 1)::float8 AS minutes,
bool_or(accessible)::int AS accessible
FROM trip_hops
WHERE to_station IS NOT NULL AND to_station <> from_station
GROUP BY from_station, to_station, line, route_type
HAVING avg(minutes) IS NOT NULL;
RESET provsql.active;
The table has tens of thousands of rows and takes up to a minute to build,
over the millions of rows of stop_times. Since the rewriting was off,
its rows carry no provenance at all: the link with the tokens of trips
and stops is lost, and accessible is just a copied value, no longer
a formula over the trips and stops it came from. add_provenance makes
hop a base relation of its own, each hop with a fresh token, which the
queries below combine like those of any other input; map these tokens to
the times and accessibility of their hops:
SELECT add_provenance('hop');
SELECT create_provenance_mapping('hop_minutes', 'hop', 'minutes');
SELECT create_provenance_mapping('hop_access', 'hop', 'accessible');
Step 8: Every Station Reachable from Luxembourg
Note
Recursive queries (WITH RECURSIVE), used from this step on, require
PostgreSQL ≥ 15.
A journey with changes is a path in this network: a recursive query follows the hops from Luxembourg, a line change being just another hop out of the same station:
WITH RECURSIVE reach(station) AS (
SELECT 'IDFM:71161'::text
UNION
SELECT h.to_station FROM reach r JOIN hop h ON h.from_station = r.station)
SELECT count(*) FROM reach;
The whole network, in a few seconds. The network is full of cycles (every line runs both ways), so a station has infinitely many derivations: ProvSQL records the provenance of each row as one unknown of a system of equations over the rows of the cycle, which every evaluation then solves (see Recursive Queries).
Step 9: How Long, and Is There an Accessible Journey?
The same query, evaluated in two semirings. In the tropical semiring of
sr_tropical, alternatives take the minimum and a journey the sum
of its hops: the shortest travel time. In the Boolean semiring of
sr_boolean, over the accessible column: whether some journey
uses accessible hops only. A VALUES list names a few destinations;
it has no provenance of its own:
WITH RECURSIVE reach(station) AS (
SELECT 'IDFM:71161'::text
UNION
SELECT h.to_station FROM reach r JOIN hop h ON h.from_station = r.station)
SELECT d.name,
round(sr_tropical(provenance(), 'hop_minutes',
nonnegative => true)::numeric, 1) AS minutes,
sr_boolean(provenance(), 'hop_access') AS accessible
FROM reach r
JOIN (VALUES ('IDFM:474151', 'Châtelet - Les Halles'),
('IDFM:71410', 'Gare du Nord'),
('IDFM:71517', 'La Défense'),
('IDFM:73721', 'Versailles Château Rive Gauche'),
('IDFM:73699', 'Aéroport CDG (Terminal 2)'),
('IDFM:68385', 'Marne-la-Vallée - Chessy'))
AS d(station, name)
USING (station)
ORDER BY minutes;
Châtelet - Les Halles, two stops away on RER B, comes first; the airport and Chessy, at the far ends of the network, last.
These are times on board only, well below what a traveller would see. In
this network, changing lines is instant, and a train is always there to
board: a journey is free to hop from line to line at every station, and
the fastest ones do, catching a bus or a night bus for a single stop when
it saves a minute. Real journeys add, at every change, the walk between
platforms, sometimes several minutes in a large station, and, at every
boarding, the wait for the next departure. The dataset has what it takes
to account for both: the departure times are in stop_times, and a file
this case study does not load, transfers.txt, gives the minimum time to
change between two stops (another, pathways.txt, even gives the walks
from the entrances of a station, with their stairs). A model using them would
have a node per station and line, with change hops between them, or, to
count the waits, a node per departure; the queries stay the same recursion
over a larger table.
Each semiring solves the equations by the method its properties allow.
The Boolean semiring, and the tropical one over the nonnegative costs that
nonnegative => true declares, are absorptive (a cycle never improves
a journey) and selective (an alternative is one of the two), which
makes Dijkstra’s algorithm correct; without nonnegative, a cost might be
negative and a cycle could lower it, and the equations are solved by
Gaussian elimination instead, for the same values here.
Step 10: Not Every Semiring Has an Answer
Counting the derivations of a station in the counting semiring:
SELECT create_provenance_mapping('hop_one', 'hop', '1');
WITH RECURSIVE reach(station) AS (
SELECT 'IDFM:71161'::text
UNION
SELECT h.to_station FROM reach r JOIN hop h ON h.from_station = r.station)
SELECT sr_counting(provenance(), 'hop_one')
FROM reach WHERE station = 'IDFM:474151';
is refused: going round a cycle any number of times gives infinitely many derivations, which no integer counts.
Step 11: The Equations of a Recursion
sr_formula shows the equations themselves, and
sr_regex their solution, as a regular expression over the hops.
On the whole network they are far too long to read; here they are
restricted to RER B between Luxembourg and its two neighbours. Label
every metro and RER hop (route_type 1 and 2) with its line and
stations. plain(NULL::stops) reads the station names without their
provenance, so that provenance() is the token of the hop alone, and
the stops’ own tokens stay out of the result as well:
CREATE TABLE hop_name AS
SELECT provenance(), h.line || ': ' || f.stop_name || ' → ' || t.stop_name
AS value
FROM hop h JOIN plain(NULL::stops) f ON f.stop_id = h.from_station
JOIN plain(NULL::stops) t ON t.stop_id = h.to_station
WHERE h.route_type IN (1, 2);
WITH RECURSIVE reach(station) AS (
SELECT 'IDFM:71161'::text
UNION
SELECT h.to_station FROM reach r JOIN hop h ON h.from_station = r.station
WHERE h.line = 'B'
AND h.to_station IN ('IDFM:71161', 'IDFM:71106', 'IDFM:73620'))
SELECT s.stop_name, sr_formula(provenance(), 'hop_name'),
sr_regex(provenance(), 'hop_name')
FROM reach r JOIN plain(NULL::stops) s ON s.stop_id = r.station;
For Port Royal, the equations are:
x₂ where x₁ = x₃ ⊗ B: Luxembourg → Saint-Michel Notre-Dame,
x₂ = x₃ ⊗ B: Luxembourg → Port Royal,
x₃ = 𝟙 ⊕ (x₂ ⊗ B: Port Royal → Luxembourg)
⊕ (x₁ ⊗ B: Saint-Michel Notre-Dame → Luxembourg)
One unknown per station, numbered as in the circuit Studio draws:
Luxembourg (x₃) holds from the start (𝟙) or by coming back from
either neighbour, and each neighbour by a hop from Luxembourg. The regular expression lists the journeys themselves, any
number of round trips from Luxembourg (*, with | between the two
neighbours), then the hop to Port Royal:
(B: Luxembourg → Port Royal · B: Port Royal → Luxembourg
| B: Luxembourg → Saint-Michel Notre-Dame
· B: Saint-Michel Notre-Dame → Luxembourg)*
· B: Luxembourg → Port Royal
Unlike the other semirings so far, the concatenation · of
sr_regex does not commute, and follows the order of the query:
reach comes first in the recursive FROM clause, so that each
journey reads in the order it is made.
Step 12: The Fastest Journey Itself
sr_tropical gives the shortest travel time, not the journey
that takes it. sr_tropical_paths gives both: the least cost,
from a first mapping, and the journeys of that cost, as a regular
expression over the labels of a second one, alternatives of the same cost
joined by |. With the labels of Step 11, look for La Défense, on
metro and RER only:
WITH RECURSIVE reach(station) AS (
SELECT 'IDFM:71161'::text
UNION
SELECT h.to_station FROM reach r JOIN hop h ON h.from_station = r.station
WHERE h.route_type IN (1, 2))
SELECT (sr_tropical_paths(provenance(), 'hop_minutes', 'hop_name')).*
FROM reach WHERE station = 'IDFM:71517';
The fastest journey takes RER B to Châtelet - Les Halles, and changes there to RER A:
B: Luxembourg → Saint-Michel Notre-Dame
· B: Saint-Michel Notre-Dame → Châtelet - Les Halles
· A: Châtelet - Les Halles → Auber · A: Auber → Charles de Gaulle - Étoile
· A: Charles de Gaulle - Étoile → La Défense
Step 13: One Journey, Step by Step
Two journeys may take the same time. From Luxembourg to La Motte-Picquet -
Grenelle, the fastest journeys part at Gare Montparnasse, for metro 6 over
four stops or metro 13 then 10 over three. Depending on the version of the
dataset loaded, both may take the same time, and
sr_tropical_paths then lists both:
WITH RECURSIVE reach(station) AS (
SELECT 'IDFM:71161'::text
UNION
SELECT h.to_station FROM reach r JOIN hop h ON h.from_station = r.station
WHERE h.route_type IN (1, 2))
SELECT (sr_tropical_paths(provenance(), 'hop_minutes', 'hop_name')).paths
FROM reach WHERE station = 'IDFM:71199';
B: Luxembourg → Port Royal · B: Port Royal → Denfert-Rochereau
· 6: Denfert-Rochereau → Raspail · 4: Raspail → Vavin
· 4: Vavin → Gare Montparnasse
· (6: Gare Montparnasse → Pasteur · 6: Pasteur → Sèvres-Lecourbe
· 6: Sèvres-Lecourbe → Cambronne
· 6: Cambronne → La Motte-Picquet - Grenelle
| 13: Gare Montparnasse → Duroc · 10: Duroc → Ségur
· 10: Ségur → La Motte-Picquet - Grenelle)
sr_best_path takes the same two mappings and keeps one journey
of least time, the one with the fewest hops (then the first in
alphabetical order of its hops), here through Duroc. It returns its hops
as an array, in the order they are made, which unnest turns into an
itinerary. To visit Provins, a medieval town in the east of the region:
WITH RECURSIVE reach(station) AS (
SELECT 'IDFM:71161'::text
UNION
SELECT h.to_station FROM reach r JOIN hop h ON h.from_station = r.station
WHERE h.route_type IN (1, 2))
SELECT round(j.cost::numeric, 1) AS minutes, step, hop
FROM (SELECT (sr_best_path(provenance(), 'hop_minutes', 'hop_name')).*
FROM reach WHERE station = 'IDFM:62139') j,
unnest(j.path) WITH ORDINALITY AS u(hop, step)
ORDER BY step;
minutes | step | hop
---------+------+------------------------------------------------------
79.3 | 1 | B: Luxembourg → Saint-Michel Notre-Dame
79.3 | 2 | B: Saint-Michel Notre-Dame → Châtelet - Les Halles
79.3 | 3 | B: Châtelet - Les Halles → Gare du Nord
79.3 | 4 | 4: Gare du Nord → Gare de l'Est
79.3 | 5 | TER: Gare de l'Est → Longueville
79.3 | 6 | P: Longueville → Sainte-Colombe Septveilles
79.3 | 7 | P: Sainte-Colombe Septveilles → Champbenoist Poigny
79.3 | 8 | P: Champbenoist Poigny → Provins
Over the whole network, buses included, journeys of the same time
are the rule rather than the exception, and the regular expression of
sr_tropical_paths grows long, where sr_best_path
still returns one journey.
Step 14: Metro or Train?
From Gare de Lyon, which of the other railway terminals of Paris are
faster to reach by metro than by train (the RER and the suburban trains,
route_type 2)? Each kind of journey is a recursive query, and
EXCEPT takes the train journeys away from the metro ones:
WITH RECURSIVE
metro(station) AS (
SELECT 'IDFM:73626'::text
UNION
SELECT h.to_station FROM metro m JOIN hop h ON h.from_station = m.station
WHERE h.route_type = 1),
train(station) AS (
SELECT 'IDFM:73626'::text
UNION
SELECT h.to_station FROM train t JOIN hop h ON h.from_station = t.station
WHERE h.route_type = 2)
SELECT d.name, round(j.cost::numeric, 1) AS minutes, cardinality(j.path) AS hops
FROM (SELECT station,
(sr_best_path(provenance(), 'hop_minutes', 'hop_name')).*
FROM (SELECT station FROM metro EXCEPT SELECT station FROM train) t) j
JOIN (VALUES ('IDFM:71135', 'Gare d''Austerlitz'),
('IDFM:71359', 'Gare de l''Est'),
('IDFM:71410', 'Gare du Nord'),
('IDFM:71139', 'Gare Montparnasse'),
('IDFM:71370', 'Gare Saint-Lazare'))
AS d(station, name)
USING (station)
ORDER BY minutes;
name | minutes | hops
-------------------+----------+------
Gare d'Austerlitz | 5.9 | 3
Gare Saint-Lazare | 8.4 | 4
Gare de l'Est | 10.3 | 7
Gare Montparnasse | 12.0 | 8
Gare du Nord | Infinity |
All five are reached both ways, so that plain SQL would return none of
them. In sr_best_path, the difference of two journeys is the
first one where it is better than the second (faster or, as fast, with
fewer hops, then first in alphabetical order of its hops), and nothing
otherwise: the metro wins to four of the terminals, with the journey that
does, while the train is faster to Gare du Nord (RER A then B, through
Châtelet - Les Halles), which has no journey left.
Step 15: A Day of Rail Disruptions
Suppose each rail hop runs with probability 0.95, and each bus hop with probability 0.9:
SELECT set_prob(provenance(),
CASE WHEN route_type = 3 THEN 0.9 ELSE 0.95 END)
FROM hop;
(The query returns one empty row per hop.)
The probability of still reaching a station is a network-reliability problem, hard in general. An exact answer would expand the equations into a Boolean circuit of billions of gates, and is refused:
WITH RECURSIVE reach(station) AS (
SELECT 'IDFM:71161'::text
UNION
SELECT h.to_station FROM reach r JOIN hop h ON h.from_station = r.station)
SELECT probability_evaluate(provenance())
FROM reach WHERE station = 'IDFM:73699';
An approximation with an additive guarantee (see Choosing a guarantee, not a method) samples instead, solving the equations in each sampled world. With the buses, every destination is reached in practically every world; on the rail network alone, the ends of the branches show:
WITH RECURSIVE reach(station) AS (
SELECT 'IDFM:71161'::text
UNION
SELECT h.to_station FROM reach r JOIN hop h ON h.from_station = r.station
WHERE h.route_type <> 3)
SELECT d.name,
round(probability_evaluate(provenance(), 'additive',
'eps=0.01,delta=0.05')::numeric, 3) AS p
FROM reach r
JOIN (VALUES ('IDFM:474151', 'Châtelet - Les Halles'),
('IDFM:71517', 'La Défense'),
('IDFM:73721', 'Versailles Château Rive Gauche'),
('IDFM:73699', 'Aéroport CDG (Terminal 2)'),
('IDFM:68385', 'Marne-la-Vallée - Chessy'))
AS d(station, name)
USING (station)
ORDER BY p DESC;
The centre of the network, with many alternatives, stays almost certain to be reached; Chessy, at the end of a branch of RER A, depends on every hop of that branch, and is markedly less likely. Each estimate is within 0.01 of the true probability with probability 95%.
These estimates assume that every hop runs or not independently of all the others. In practice, the hops of the same line are correlated: a signal failure or a strike stops a whole stretch of a line at once, and an express train and the stopping service between the same stations, two alternatives here, share the same track. Such correlations would lower these probabilities a lot.