🚇 Metro Travel

2024-03-27

Cette migration vers l'usage de GTFS est plutôt intéressante. Cela me permet de résoudre plusieurs choses :

  • Avoir un mode par temps de trajet
  • Avoir un affichage des stations plus précises (l'unique point par station)
  • Avoir toutes les correspondances possibles (ex: Saint-Lazare - St-Augustin)
  • Être en mesure d'ajouter le RER, le bus, le tramway, etc.
  • Être en mesure de pouvoir faire d'autres villes avec quelques changements

Import des données

Hier, je me suis arrêté à un moment où je streamais les données parsées pour les enregistrer en base de données. Seulement, à la fin du script, je n'avais pas tout en base. Regardons de plus près.

❯ wc -l ~/Downloads/IDFM-gtfs/trips.txt
  440474 /Users/vincenthardouin/Downloads/IDFM-gtfs/trips.txt

Il y a 440 474 lignes dans le fichier trips.txt, moins 1 ligne de header et j'ai 433 lignes dans ma base de données.

❯ pgcli postgresql://postgres@localhost:5432/idfm
Server: PostgreSQL 14.10
Version: 4.0.1
Home: http://pgcli.com
postgres@localhost:idfm> select count(*) from trips;
+-------+
| count |
|-------|
| 433   |
+-------+
SELECT 1
Time: 0.014s

Une légère différence …

Voici mon implémentation de stream :

await new Promise((resolve, reject) => {
  stream.on('data', async (chunk) => {
    await saveFunction([chunk]);
  });
  stream.on('end', () => {
    resolve();
  });
  stream.on('error', (error) => {
    reject(error);
  });
});

Je suppose que l'async callback ne fonctionne pas, ce qui parait assez logique. Je transforme donc le code sous cette forme :

for await (const chunk of stream) {
  await saveFunction([chunk]);
}

Je relance le script, c'est long, mais ça fonctionne bien. Là, on commit 1 à 1 chaque ligne. Je me dis que je peux envoyer des batchs :

for await (const data of stream) {
  chunks.push(data);
  if (chunks.length > 10000) {
    await saveFunction(chunks);
    chunks = [];
  }
}

Je relance le script, c'est plus rapide. On commit par batch de 1000 lignes. 10 secondes pour 413 000 lignes, testons par 10 000. J'augmente donc à 10 000 la taille du batch de cette fonction est aussi dans le batchInsert de knex, j'ai les mêmes résultats. Je reste à 1 000.

Oups, durant l'import, j'ai l'erreur : hint: 'Check free disk space.',. Je ne dois pas allouer assez de place pour docker. Mais la base est si grosse que ça ? Je regarde :

postgres@localhost:idfm> SELECT pg_size_pretty( pg_database_size('idfm') );
+----------------+
| pg_size_pretty |
|----------------|
| 1912 MB        |
+----------------+
SELECT 1
Time: 0.033s

Ah 2 Go quand même. Bon, naïvement, je lance un docker system prune. Je récupère 3Go. Je relance. En 4 minutes 55, j'ai tout en base. Je ne sais pas quoi en penser niveau performance, mais ce n'est déjà pas si mal. Le map sur les 1000 éléments à chaque insertion ne doit pas aider, mais ce n'est pas le sujet.

Je vais pouvoir passer aux requêtes sql pour récupérer les données.

Export des données

Hier, j'ai fait un semblant de json qui me plairait bien :

{
  "routes": [
    {
      "route_id": "route_id",
      "route_name": "route_name",
      "route_color": "route_color"
    }
  ],
  "stops": [
    {
      "name": "stop_name",
      "id": "stop_id",
      "stop_unique_id": "stop_unique_id",
      "route_id": "route_id"
    }
  ],
  "adjacents": [
    {
      "from_stop_id": "stop_id",
      "to_stop_id": "adjacent_stop_id",
      "time": 123,
      "path": [
        {
          "lat": 48.123,
          "lon": 2.123
        }
      ]
    }
  ],
  "stops_unique": [
    {
      "id": "stop_unique_id",
      "name": "stop_name",
      "lat": 48.123,
      "lon": 2.123
    }
  ]
}

Routes

Je commence par les routes, dans mon cas, je ne veux que les lignes de métros. En basant sur le document GTFS de IDFM, je peux voir que le route_type est à 1 pour les métros. Faisons du TDD, après ce moment sans tests, pour l'import.

Stops

Ensuite, les stops, ça va être plus sympa. Pour les stops, il faut faire la jointure avec stop_times, puis trips, et les liés aux routes. En faisant cette requête :

select distinct(stop_name)
from stops
inner join stop_times on stops.stop_id = stop_times.stop_id
inner join trips on trips.trip_id = stop_times.trip_id
inner join routes on routes.route_id = trips.route_id
where routes.route_type = 1;

J'ai la réponse en 2s, et 0.6s au deuxième appel (avec du cache). C'est clairement OK, merci PG <3.

Adjacents

Pour les adjacents, il y a plusieurs calculs à faire.

Adjacents sur une même ligne

Il faut d'abord récupérer les adjacents sur une même ligne, pour ça il faut se baser sur les stop_times grâce au stop_sequence. Si la station a un stop_sequence de 5, alors ses adjacents sont les stations 4 et 6.

J'obtiens quelque chose avec cette requête :

SELECT DISTINCT s.stop_name AS station_name,
        st.stop_sequence AS current_sequence,
        adjacent_stops.stop_sequence AS adjacent_sequence,
        adjacent_stops_station.stop_name AS adjacent_station_name,
        r.route_short_name AS line_number
 FROM stop_times AS st
 INNER JOIN stop_times AS adjacent_stops ON st.trip_id = adjacent_stops.trip_id
 INNER JOIN stops AS s ON st.stop_id = s.stop_id
 INNER JOIN stops AS adjacent_stops_station ON adjacent_stops.stop_id = adjacent_stops_station.stop_id
 INNER JOIN trips AS t ON st.trip_id = t.trip_id
 INNER JOIN routes AS r ON t.route_id = r.route_id
 WHERE ABS(st.stop_sequence - adjacent_stops.stop_sequence) = 1
 AND r.route_type = 1;

Elle met 5s, c'est plutôt correct sur 9 millions de lignes et pour un one-shot.

Ce qui m'étonne avec cette requête, c'est que par exemple si on prends Alésia, on a :

+-----------------------------------------+------------------+-------------------+-------------------------------------+-------------+
| station_name                            | current_sequence | adjacent_sequence | adjacent_station_name               | line_number |
|-----------------------------------------+------------------+-------------------+-------------------------------------+-------------|
| Alésia                                  | 1                | 0                 | Porte d'Orléans                     | 4           |
| Alésia                                  | 1                | 2                 | Mouton-Duvernet                     | 4           |
| Alésia                                  | 4                | 3                 | Porte d'Orléans                     | 4           |
| Alésia                                  | 4                | 5                 | Mouton-Duvernet                     | 4           |
| Alésia                                  | 19               | 18                | Mouton-Duvernet                     | 4           |
| Alésia                                  | 19               | 20                | Porte d'Orléans                     | 4           |
| Alésia                                  | 24               | 23                | Mouton-Duvernet                     | 4           |
| Alésia                                  | 24               | 25                | Porte d'Orléans                     | 4           |

On peut voir les aller-retour par exemple dans un sens, Alésia et la deuxième station (current_sequence = 1) et a donc comme stations adjacentes : Porte d'Orléans et Mouton-Duvernet. Dans l'autre sens, Alésia, c'est la 19e station avec les mêmes stations adjacentes. Mais, c'est aussi la 4e station et la 19e, comme si la ligne était raccourcie à un moment donné ou rallongée d'ailleurs.

Si j'affiche le trip_headsign, je pourrais en savoir plus

+-----------------------------------------+------------------+-------------------+-------------------------------------+-------------+-------------------------------------------------+
| station_name                            | current_sequence | adjacent_sequence | adjacent_station_name               | line_number | trip_name                                       |
|-----------------------------------------+------------------+-------------------+-------------------------------------+-------------+-------------------------------------------------|
| Alésia                                  | 1                | 0                 | Porte d'Orléans                     | 4           | Porte de Clignancourt                           |
| Alésia                                  | 1                | 2                 | Mouton-Duvernet                     | 4           | Porte de Clignancourt                           |
| Alésia                                  | 4                | 3                 | Porte d'Orléans                     | 4           | Porte de Clignancourt                           |
| Alésia                                  | 4                | 5                 | Mouton-Duvernet                     | 4           | Porte de Clignancourt                           |
| Alésia                                  | 19               | 18                | Mouton-Duvernet                     | 4           | Bagneux - Lucie Aubrac                          |
| Alésia                                  | 19               | 20                | Porte d'Orléans                     | 4           | Bagneux - Lucie Aubrac                          |
| Alésia                                  | 24               | 23                | Mouton-Duvernet                     | 4           | Bagneux - Lucie Aubrac                          |
| Alésia                                  | 24               | 25                | Porte d'Orléans                     | 4           | Bagneux - Lucie Aubrac                          |

On est clairement dans ce cas où la ligne est rallongée.

Bon dans tous les cas ça ne change pas les stations adjacentes, mais c'est intéressant à savoir.

Pour la version sans doublon :

SELECT DISTINCT s.stop_name AS station_name,
     adjacent_stops_station.stop_name AS adjacent_station_name
FROM stop_times AS st
INNER JOIN stop_times AS adjacent_stops ON st.trip_id = adjacent_stops.trip_id
INNER JOIN stops AS s ON st.stop_id = s.stop_id
INNER JOIN stops AS adjacent_stops_station ON adjacent_stops.stop_id = adjacent_stops_station.stop_id
INNER JOIN trips AS t ON st.trip_id = t.trip_id
INNER JOIN routes AS r ON t.route_id = r.route_id
WHERE ABS(st.stop_sequence - adjacent_stops.stop_sequence) = 1
AND r.route_type = 1;

J'obtiens quelque chose comme ça :

+-----------------------------------------+-----------------------------------------+
| station_name                            | adjacent_station_name                   |
|-----------------------------------------+-----------------------------------------|
| Abbesses                                | Lamarck - Caulaincourt                  |
| Abbesses                                | Pigalle                                 |
| Aimé Césaire                            | Front Populaire                         |
| Aimé Césaire                            | Mairie d'Aubervilliers                  |
| Alexandre Dumas                         | Avron                                   |
| Alexandre Dumas                         | Philippe Auguste                        |
| Alma - Marceau                          | Franklin D. Roosevelt                   |
| Alma - Marceau                          | Iéna                                    |
| Alésia                                  | Mouton-Duvernet                         |
| Alésia                                  | Porte d'Orléans                         |

Je n'ai pas besoin du nom des stations, mais uniquement des ids, je peux donc supprimer 2 jointures.

SELECT DISTINCT st.stop_id AS stop_id,
    adjacent_stops.stop_id AS adjacent_stop_id
FROM stop_times AS st
INNER JOIN stop_times AS adjacent_stops ON st.trip_id = adjacent_stops.trip_id
INNER JOIN trips AS t ON st.trip_id = t.trip_id
INNER JOIN routes AS r ON t.route_id = r.route_id
WHERE ABS(st.stop_sequence - adjacent_stops.stop_sequence) = 1
    AND r.route_type = 1

Par contre, je n'ai pas du tout pris en compte le temps de trajet entre les stations, je vais devoir le faire.

J'obtiens :

 SELECT DISTINCT
     st.stop_id AS stop_id,
     adjacent_stops.stop_id AS adjacent_stop_id,
     st.arrival_time::interval - adjacent_stops.arrival_time::interval AS duration,
     t.trip_headsign AS trip_headsign,
     t.direction_id AS direction_id,
     r.route_short_name AS route_name
 FROM stop_times AS st
 INNER JOIN stop_times AS adjacent_stops ON st.trip_id = adjacent_stops.trip_id
 INNER JOIN trips AS t ON st.trip_id = t.trip_id
 INNER JOIN routes AS r ON t.route_id = r.route_id
 WHERE ABS(st.stop_sequence - adjacent_stops.stop_sequence) = 1
  AND r.route_type = 1;

Dont voici le résultat :

+------------+------------------+------------------+-------------------------------------------------+--------------+------------+
| stop_id    | adjacent_stop_id | duration         | trip_headsign                                   | direction_id | route_name |
|------------+------------------+------------------+-------------------------------------------------+--------------+------------|
| IDFM:21784 | IDFM:463056      | 0:02:00          | Créteil-Pointe du Lac                           | 1            | 8          |
| IDFM:21784 | IDFM:463056      | 0:03:00          | Créteil-Pointe du Lac                           | 1            | 8          |
| IDFM:21902 | IDFM:463250      | -1 day, 23:58:00 | Gallieni (Parc de Bagnolet)                     | 0            | 3          |
| IDFM:21902 | IDFM:463250      | -1 day, 23:59:00 | Gallieni (Parc de Bagnolet)                     | 0            | 3          |
| IDFM:21902 | IDFM:463316      | 0:01:00          | Gallieni (Parc de Bagnolet)                     | 0            | 3          |
| IDFM:21902 | IDFM:463316      | 0:02:00          | Gallieni (Parc de Bagnolet)                     | 0            | 3          |

On peut constater plusieurs soucis, la durée en négatif et surtout pas la durée pour un même trajet.

En faisant un case, je résous le problème de la durée négative :

 SELECT DISTINCT
     st.stop_id AS stop_id,
     adjacent_stops.stop_id AS adjacent_stop_id,
     CASE
          WHEN st.arrival_time::interval <= adjacent_stops.arrival_time::interval
              THEN adjacent_stops.arrival_time::interval - st.arrival_time::interval
          ELSE st.arrival_time::interval - adjacent_stops.arrival_time::interval
      END AS duration,
     t.trip_headsign AS trip_headsign,
     t.direction_id AS direction_id,
     r.route_short_name AS route_name
 FROM stop_times AS st
 INNER JOIN stop_times AS adjacent_stops ON st.trip_id = adjacent_stops.trip_id
 INNER JOIN trips AS t ON st.trip_id = t.trip_id
 INNER JOINroutes AS r ON t.route_id = r.route_id
 WHERE ABS(st.stop_sequence - adjacent_stops.stop_sequence) = 1
    AND r.route_type = 1;

Pour ma culture j'ai voulu voir les arrêts les plus longs:

WITH stops_and_adjacents AS (
    SELECT DISTINCT
        st.stop_id AS from_stop_id,
        s.stop_name AS from_stop_name,
        adjacent_stops.stop_id AS to_stop_id,
        adjacent_stops_station.stop_name AS to_stop_name,
        CASE
            WHEN st.arrival_time::interval <= adjacent_stops.arrival_time::interval
                THEN (adjacent_stops.arrival_time::interval - st.arrival_time::interval)::TEXT
            ELSE (st.arrival_time::interval - adjacent_stops.arrival_time::interval)::TEXT
        END AS duration
    FROM
        stop_times AS st
    INNER JOIN
        stop_times AS adjacent_stops ON st.trip_id = adjacent_stops.trip_id
    INNER JOIN
        trips AS t ON st.trip_id = t.trip_id
    INNER JOIN stops AS s ON st.stop_id = s.stop_id
    INNER JOIN stops AS adjacent_stops_station ON adjacent_stops.stop_id = adjacent_stops_station.stop_id
    INNER JOIN
        routes AS r ON t.route_id = r.route_id
    WHERE
        ABS(st.stop_sequence - adjacent_stops.stop_sequence) = 1
        AND r.route_type = 1
)
SELECT
    from_stop_id,
    from_stop_name,
    to_stop_id,
    to_stop_name,
    MIN(duration) AS min_duration
FROM
    stops_and_adjacents
GROUP BY
    from_stop_id,
    from_stop_name,
    to_stop_id,
    to_stop_name
ORDER BY min_duration DESC;

Et voici les résultats :

+--------------+-----------------------------------------+-------------+-----------------------------------------+--------------+
| from_stop_id | from_stop_name                          | to_stop_id  | to_stop_name                            | min_duration |
|--------------+-----------------------------------------+-------------+-----------------------------------------+--------------|
| IDFM:463221  | Mairie de Montreuil                     | IDFM:21913  | Porte de Montreuil                      | 00:06:00     |
| IDFM:21913   | Porte de Montreuil                      | IDFM:463221 | Mairie de Montreuil                     | 00:06:00     |
| IDFM:463131  | Bobigny Pablo Picasso                   | IDFM:22014  | Bobigny-Pantin - Raymond Queneau        | 00:04:00     |
| IDFM:22014   | Bobigny-Pantin - Raymond Queneau        | IDFM:463131 | Bobigny Pablo Picasso                   | 00:04:00     |
| IDFM:463002  | Bobigny-Pantin - Raymond Queneau        | IDFM:22015  | Bobigny Pablo Picasso                   | 00:03:00     |
| IDFM:463159  | Châtelet                                | IDFM:21958  | Gare de Lyon                            | 00:03:00     |
| IDFM:21959   | Châtelet                                | IDFM:463046 | Gare de Lyon                            | 00:03:00     |
| IDFM:22015   | Bobigny Pablo Picasso                   | IDFM:463002 | Bobigny-Pantin - Raymond Queneau        | 00:03:00     |
| IDFM:21958   | Gare de Lyon                            | IDFM:463159 | Châtelet                                | 00:03:00     |
| IDFM:463046  | Gare de Lyon                            | IDFM:21959  | Châtelet                                | 00:03:00     |

Ce qui est intéressant, c'est que le chemin : Mairie de Montreuil -> Porte de Montreuil, ne devrait pas exister, car il y a des stations entre les deux.

Oups, je me rends compte que ce qui peut m'aider c'est dans la table stop_times, il y a un pickup_type et drop_off_type mais je n'ai pas fait la colonne. Je vais devoir la rajouter.

D'ailleurs, j'en ai un peu marre de regarder le csv dans TextEdit, j'installe csvlens, c'est plutôt pas mal.

En regardant les données après avoir ajouté les colonnes, je me rends qu'il y a pas mal où on ne peut pas monter ou descendre :

postgres@localhost:idfm> select count(*), count(*) filter(where drop_off_type = 1), count(*) filter(where pickup_type =1) from stop_times;
+---------+--------+--------+
| count   | count  | count  |
|---------+--------+--------|
| 9761752 | 442360 | 441737 |
+---------+--------+--------+
SELECT 1
Time: 0.958s

En regardant du coup notre cas :

postgres@localhost:idfm> SELECT DISTINCT
         st.stop_id AS from_stop_id,
         s.stop_name AS from_stop_name,
         adjacent_stops.stop_id AS to_stop_id,
         adjacent_stops_station.stop_name AS to_stop_name,
         CASE
             WHEN st.arrival_time::interval <= adjacent_stops.arrival_time::interval
                 THEN (adjacent_stops.arrival_time::interval - st.arrival_time::interval)::TEXT
             ELSE (st.arrival_time::interval - adjacent_stops.arrival_time::interval)::TEXT
         END AS duration,
         st.pickup_type, st.drop_off_type, adjacent_stops.pickup_type, adjacent_stops.drop_off_type
     FROM
         stop_times AS st
     INNER JOIN
         stop_times AS adjacent_stops ON st.trip_id = adjacent_stops.trip_id
     INNER JOIN
         trips AS t ON st.trip_id = t.trip_id
     INNER JOIN stops AS s ON st.stop_id = s.stop_id
     INNER JOIN stops AS adjacent_stops_station ON adjacent_stops.stop_id = adjacent_stops_station.stop_id
     INNER JOIN
         routes AS r ON t.route_id = r.route_id
     WHERE
         ABS(st.stop_sequence - adjacent_stops.stop_sequence) = 1
         AND r.route_type = 1 AND st.stop_id = 'IDFM:463221'
+--------------+---------------------+-------------+--------------------+----------+-------------+---------------+-------------+---------------+
| from_stop_id | from_stop_name      | to_stop_id  | to_stop_name       | duration | pickup_type | drop_off_type | pickup_type | drop_off_type |
|--------------+---------------------+-------------+--------------------+----------+-------------+---------------+-------------+---------------|
| IDFM:463221  | Mairie de Montreuil | IDFM:21913  | Porte de Montreuil | 00:06:00 | 0           | 1             | 1           | 0             |
| IDFM:463221  | Mairie de Montreuil | IDFM:463176 | Croix de Chavaux   | 00:01:00 | 0           | 1             | 0           | 0             |
| IDFM:463221  | Mairie de Montreuil | IDFM:463176 | Croix de Chavaux   | 00:02:00 | 0           | 1             | 0           | 0             |
+--------------+---------------------+-------------+--------------------+----------+-------------+---------------+-------------+---------------+

Donc ce qu'on peut voir c'est qu'à Mairie de Montreuil, c'est un arrêt où on peut que monter, mais on ne peut pas descendre (le train doit faire une boucle ou autres). Et à son arrivée à Porte de Montreuil, on ne peut que descendre.

On est vraiment dans un cas à la marge, je pense. Est-ce que je ne pourrais pas avoir tous les bouts de lignes pour éviter ce genre de cas ?

Adjacents sur une correspondance

Exemple : Saint-Lazare : Je peux changer de la ligne 13 à la 12, 14, …

Il faut donc récupérer les pathways et les stops pour avoir les correspondances.

Ressources