@ Brest

Relier et partager autour du web

Comment utiliser les données de Keolis et du SIG à Rennes, retour d’expérience

D’une donnée brute à une donnée enrichie

Cet article va tenter de décrire mes étapes pour exploiter certaines données géographiques mises à disposition par Keolis Rennes et le Service d’Information Géographique (SIG) de Rennes métropole.

Le pitch

Mon besoin était de m’appuyer sur l’empreinte du réseau de transports de bus dans la métropole pour déterminer le tracé précis entre deux arrêts de bus consécutifs sur une ligne donnée, et d’en calculer la longeur.

Un peu de naïveté ne fait pas de mal

Ma première idée fut de me tourner vers le portail de Keolis. En effet, il me semblait naturel d’y trouver les données des itinéraires même si je ne m’attendais pas à ce qu’elles soient publiées via leur API. Sur ce point, j’avais raison, l’API ne s’intéresse qu’aux données de l’état du réseau.

Keolis propose aussi quelques jeux de données statiques téléchargeables directement. Malheureusement, hormis les données graphiques des pictogrammes des lignes, Keolis ne publie que le GTFS qui représente les informations de transits, mais pas les données géographiques.

La première étape fit donc choux blanc.

Bon sang, mais c’est bien sûr !

Puisque Keolis ne fournissait pas les données dont j’avais besoin, je me suis tourné vers le grand service libre de données géographiques : OpenStreetMap. En effet, OSM est très actif et il me semblait probable d’y trouver ce que je cherchais.

J’atterris donc rapidement sur le wiki d’OSM listant les jeux couvrants le réseau de transport en commun sur la métropole. Mais là trois obstacles se dressèrent devant moi :

  • L’ensemble du réseau n’est pas couvert
  • Certains itinéraires sont incomplets
  • Les données sont moins directement exploitables que je n’aurais espérer

En revanche, un des grands intérêts d’OSM est qu’il est mis à jour régulièrement, même lorsque des travaux prennent place durablement, modifiant le tracé d’une ligne de bus.

Le problème des itinéraires incomplets pourrait se résoudre simplement en éditant OSM. Malheureusement, même si cette tâche est désormais facilitée depuis un simple navigateur grâce aux outils d’OSM, ils ne peuvent pas résoudre des cas plus compliqués tels que celui illustré ci-dessous :

osm_ligne67

Voir directement la source

 

Si vous le notez bien, la ligne 67 quitte la voie centrale pour rejoindre l’avenue Maginot, avant de tourner au nord par le pont de Strasbourg. Or, le tracé est interrompu le long de cette avenue, la ligne 67 ne la parcourant qu’en partie. Les outils de bases d’OSM ne permettent pas de choisir un segment me semble-t-il. Techniquement, c’est dû au fait que l’entité qui représente l’avenue Maginot est considérée comme une ligne continue. D’ailleurs, on remarque aussi que la ligne 67 semble venir d’abord de l’avenue Maginot, mais ce n’est pas le cas. En fait, la sorte de V à l’envers est aussi un segment d’un seul tenant et l’éditeur qui l’a sélectionné n’a pas pu faire autrement que de le prendre entièrement, même sa section ouest qui n’est pas utilisé en réalité par la ligne 67.

Mes compétences en édition OSM sont trop limitées pour savoir si il est possible de créer le lien manquant. J’ai tenté ma chance avec l’éditeur avancé JOSM mais sans succès.

D’autre part, le format d’export des données est bien particulier. Il définit essentiellement un graphe de noeuds et de relations entre ces noeuds. Je dois admettre n’avoir pas poussé mes recherches pour en tirer les informations sous une forme qui me semblait utilisable pour mes besoins.

Le SIG à la rescousse

Le SIG de Rennes métropole est un service prolifique puisqu’il publie à lui seul près de 30% des données ouvertes proposées sur le portail. Au sein de cette forêt de jeux de données, j’ai pu trouver celui qui me semblait être précisément ce dont j’avais besoin : “Données géographiques du réseau STAR“.

Ce lot données contient les couches géographiques (géodonnées) décrivant le réseau de transport en commun de Rennes Métropole.

Le lot de données contient :
– les itinéraires des lignes principales
– les arrêts physiques
– les arrêts logiques
– la correspondance entre les arrêts physiques et les itinéraires de ligne (fichier CSV)

Les arrêts physiques sont des points à la localisation des arrêts de bus sur les trottoirs. Les arrêts logiques sont des points sur les lignes et représentants les arrêts physiques de la ligne dans les deux sens. Une image vaut mieux qu’un long discours :

Vert: lignes, orange : arrêts logiques, bleus : arrêts physiques

Vert : lignes, orange : arrêts logiques, bleus : arrêts physiques

Les arrêts logiques sont décrits comme étant positionnés sur la ligne. Il me semblait donc évident que la combinaison “lignes + arrêts logiques” allaient enfin répondre à mes besoins. En effet, mon objectif initial était de pouvoir couper les lignes en segments dont les extrémités seraient deux arrêts logiques sur cette ligne.

Sauf que tout n’est jamais si simple…

Ma première action fût de choisir les données au format shapefile. Ce format contient les données géographiques et peut aisément se charger dans la base de données spatiales PostGIS via les outils fournis :

$ shp2pgsql -I -S star_arret_physique.shp public.star_arret_physique | psql -U test -h localhost -d rennes_sig

Cette commande a crée une table “star_arret_physique” dans ma base de données spatiales “rennes_sig”. Voici la définition de cette table :

CREATE TABLE star_arret_physique
(
 gid serial NOT NULL,
 ap_timeo character varying(4),
 ap_type character varying(10),
 ap_nom character varying(50),
 ap_insee character varying(5),
 ap_x_cc48 numeric,
 ap_y_cc48 numeric,
 ap_x_wgs84 numeric,
 ap_y_wgs84 numeric,
 acces_pmr character varying(10),
 li_access character varying(30),
 li_norm character varying(30),
 mobilier character varying(20),
 banc character varying(5),
 eclairage character varying(5),
 poubelle character varying(5),
 al_id double precision,
 geom geometry(Point),
 CONSTRAINT star_arret_physique_pkey PRIMARY KEY (gid)
) ;

Pour les données d’itinéraires :

$ shp2pgsql -I star_ligne_itineraire.shp public.star_ligne_itineraire | psql -U test -h localhost -d rennes_sig

Qui produit la description suivante :

CREATE TABLE star_ligne_itineraire
(
 gid serial NOT NULL,
 iti_code character varying(9),
 iti_sens character varying(1),
 iti_nom character varying(200),
 iti_org character varying(4),
 iti_dest character varying(4),
 li_num character varying(10),
 li_nom character varying(200),
 li_type character varying(60),
 li_sstype character varying(60),
 li_d_acces date,
 geom geometry(MultiLineString),
 CONSTRAINT star_ligne_itineraire_pkey PRIMARY KEY (gid)
) ;

Il suffit d’exécuter la même commande sur les données de lignes et on se retrouve alors avec une base de données prêtes à être exploitée. Enfin presque. Si vous regardez bien la déclaration des deux tables, il n’existe pas de clés communes entre elles. La table des arrêts logiques n’offre aucune relation directe avec les itinéraires. Il faut en fait utiliser une table pivot constituée depuis un simple fichier CSV : star_ap_iti. Ce fichier CSV relie les arrêts physiques aux itinéraires.

L’import se passe différement. D’abord créez une table pour recevoir les données :

CREATE TABLE star_ap_iti
(
 li_code character varying(4),
 iti_code character varying(9),
 li_sens character varying(1),
 ap_ordre integer,
 ap_timeo character varying(4)
) ;

Puis copiez les données depuis un shell psql :

$ COPY star_ap_iti FROM ’/reseau_star_csv/donnees/star_ap_iti.csv’ DELIMITERS ’,’ CSV HEADER ;

Maintenant que les données sont dans la base, nous pouvons effectuer les requêtes pour lier les arrêts logiques aux lignes.

SELECT iti_code,
	ap_ordre,
	al_id
FROM star_ap_iti,
	(
		SELECT al_id,
			ap_timeo
		FROM star_arret_physique
		WHERE ap_timeo IN (
				SELECT ap_timeo
				FROM star_ap_iti
				)
		GROUP BY ap_timeo,
			al_id
		) AS t
WHERE star_ap_iti.ap_timeo = t.ap_timeo geom
ORDER BY iti_code,
	ap_ordre ;

L’idée est simple, nous parcourons la table des arrêts physiques pour obtenir les arrêts logiques associés. Puis nous procédons à une jointure avec la table créée précédemment en utilisant le code de l’arrêt physique comme pivot. Finalement, nous ordonnançons les résultats par itinéraire et position de l’arrêt sur cet itinéraire. Ci-dessous un petit extrait des données que nous obtenons :

Itinéraire ;Position ;Identifiant arrêt logique ;Point géographique (binaire)
"0001-01-A" ;1 ;9958 ;"01010000000B51A31006F3F9BFD1AF1CEB3F114840"
"0001-01-A" ;2 ;9946 ;"01010000008DAC8C7294FEF9BF6CC613EBD7104840"
"0001-01-A" ;3 ;80 ;"0101000000B0069CF7E80AFABF10FD9F7AAB104840"
"0001-01-A" ;4 ;378 ;"0101000000901E04FCD71EFABF15C7F99585104840"
"0001-01-A" ;5 ;268 ;"01010000006E24582E5B34FABF77F971966B104840"

A ce moment là, je dois avouer, je voulais crier victoire. En effet, il me semblait que j’avais enfin le lien entre les arrêts, les itinéraires la géolocalisation des arrêts et, cerise sur le gâteau, l’ordonnancement des arrêts.

Sauf que tout n’est jamais si simple (bis)…

J’ai en effet rapidement été confronté à un problème. Prenons un exemple simple, la ligne C4 et l’arrêt (logique) Pont de Strasbourg :

line4_zoom1

A une échelle assez grande, on se dit que l’arrêt est effectivement sur la ligne. Mais en zoomant, on se rend compte que non :

line4_zoom16

Cela pose une difficulté, puisque pour calculer la distance entre deux arrêts le long d’une ligne, les outils proposés par PostGIS nécessitent que les points soient contenus par la ligne. Du coup, il est impossible d’appliquer une requête simple.

La solution est de projetter le point représentant l’arrêt sur l’itinéraire via la fonction ST_ClosestPoint, et en reprenant la requête ci-dessus :

SELECT ST_ClosestPoint(star_ligne_itineraire.geom, o.geom)
FROM star_ligne_itineraire,
	(
		SELECT iti_code,
			ap_ordre,
			al_id,
			geom
		FROM star_ap_iti,
			(
				SELECT al_id,
					ap_timeo,
					geom
				FROM star_arret_physique
				WHERE ap_timeo IN (
						SELECT ap_timeo
						FROM star_ap_iti
						)
					AND ap_type = ’mobilier’
				) AS t
		WHERE star_ap_iti.ap_timeo = t.ap_timeo
		ORDER BY iti_code,
			ap_ordre
		) AS o
WHERE star_ligne_itineraire.iti_code = o.iti_code

La table résultante est l’ensemble des points projetés sur les lignes du réseau.

Une fois cette étape atteinte, il suffit de dérouler l’algorithme pour découper chaque ligne par couple de points et mesurer la longueur du segment. Cela parait simple mais, une fois traduit en SQL, c’est beaucoup moins amusant à lire :

/* Genere finalement la table contenant chaque arrete logique avec
   son point projete sur l’itineraire auquel il est associe.
   Contient le segment et sa longueur
*/
SELECT li_gid,
	iti_code,
	ap_ordre,
	al_id,
	prev_al_id,
	pos,
	prev_pos,
	CONCAT (
		iti_code,
		’_’,
		ap_ordre
		) AS segment_id,
	ST_SetSRID(closest_geom, 4326) AS closest_geom,
	ST_SetSRID(segment, 4326) AS segment,
	ST_Length_Spheroid(segment, ’SPHEROID["WGS 84",6378137,298.257223563]’) AS LENGTH
FROM (
	/* Genere desormais le segment. Si la ligne est un retour, alors
	   la position sera inversee ce qui explique la conditionnelle
	*/
	SELECT star_ligne_itineraire.gid AS li_gid,
		p.iti_code,
		ap_ordre,
		al_id,
		prev_al_id,
		pos,
		prev_pos,
		CASE 
			WHEN prev_pos &lt ; pos
				THEN ST_Line_Substring(ST_LineMerge(star_ligne_itineraire.geom), prev_pos, pos)
				ELSE ST_Line_Substring(ST_LineMerge(star_ligne_itineraire.geom), pos, prev_pos)
			END AS segment,
		closest_geom
	FROM star_ligne_itineraire,
		(
			/* Calcul la distance entre deux points en parcourant la table
			   et en operant la difference de position entre deux points consecutifs
			*/
			SELECT iti_code,
				ap_ordre,
				al_id,
				pos,
				lag(pos, 1, 0::FLOAT) OVER (
					ORDER BY iti_code,
						ap_ordre
					) AS prev_pos,
				lag(al_id, 1) OVER (
					ORDER BY iti_code,
						ap_ordre
					) AS prev_al_id,
				closest_geom
			FROM (
				/* Projette chaque arret logique sur son itineraire et
				   trouve ainsi les points qui definiront les
				   extremites des segments.
				   Calcul une valeur entre 0 et 1 representant la 
				   position du point projete le long du segment
				*/
				SELECT o.iti_code,
					ap_ordre,
					al_id,
					ST_ClosestPoint(star_ligne_itineraire.geom, o.geom) AS closest_geom,
					ST_Line_Locate_Point(ST_LineMerge(star_ligne_itineraire.geom), ST_ClosestPoint(star_ligne_itineraire.geom, o.geom)) AS pos
				FROM star_ligne_itineraire,
					(
						/* Associe maintenant les arrets logiques aux lignes */
						SELECT iti_code,
							ap_ordre,
							al_id,
							geom
						FROM star_ap_iti,
							(
								/* Cherche la liste des identifiants des arrets logiques
								   associes aux arrets physiques
								*/
								SELECT al_id,
									ap_timeo,
									geom
								FROM star_arret_physique
								WHERE ap_timeo IN (
										SELECT ap_timeo
										FROM star_ap_iti
										)
									AND ap_type = ’mobilier’
								) AS t
						WHERE star_ap_iti.ap_timeo = t.ap_timeo
						ORDER BY iti_code,
							ap_ordre
						) AS o
				WHERE star_ligne_itineraire.iti_code = o.iti_code
				ORDER BY star_ligne_itineraire.gid,
					ap_ordre
				) AS u
			) AS p
	WHERE star_ligne_itineraire.iti_code = p.iti_code
	ORDER BY star_ligne_itineraire.gid,
		ap_ordre
	) AS g
ORDER BY li_gid,
	ap_ordre ;

J’avais prévenu, c’est assez hideux mais ça fait le job. Enfin presque…

Sauf que tout n’est jamais si simple (ter)…

Lorsque l’on exécute cette requête, on obtient l’erreur suivante :

ERROR : line_locate_point : 1st arg isnt a line
CONTEXT : SQL function "st_line_locate_point" statement 2
********** Erreur **********
ERROR : line_locate_point : 1st arg isnt a line
État SQL :XX000
Contexte : SQL function "st_line_locate_point" statement 2

En effet, les lignes fournies dans le shapefile par le SIG de Rennes métropole sont en fait des multi-lignes. La plupart peuvent être combinées en lignes continues, mais pas toutes. Or, PostGIS ne sait traiter, dans certaines de ces fonctions, que des lignes continues.

J’aurais pu essayer de charger les données initialement ainsi :

$ shp2pgsql -S -I star_ligne_itineraire.shp public.star_ligne_itineraire | psql -U test -h localhost -d rennes_sig

Notez le -S dans la ligne de commande. Mais on obtient alors une erreur confirmant que certaines lignes ne peuvent pas être considérées comme continues :

Shapefile type : Arc
Postgis type : LINESTRING[2]
We have a Multilinestring with 3 parts, can’t use -S switch !

Un exemple de représentation sur l’itinéraire 6 :

multilines

 

La seule solution que j’ai trouvé est de filtrer les itinéraires que je ne peux pas combiner en des lignes continues afin de ne pas les traiter du tout. Pour ce faire, il faut modifier la requête géante comme suit :

/* Genere finalement la table contenant chaque arrete logique avec
   son point projete sur l’itineraire auquel il est associe.
   Contient le segment et sa longueur en unités de projection (mètres dans le cas de la proj WGS84).
*/
SELECT li_gid,
	iti_code,
	ap_ordre,
	al_id,
	prev_al_id,
	pos,
	prev_pos,
	CONCAT (
		iti_code,
		’_’,
		ap_ordre
		) AS segment_id,
	ST_SetSRID(closest_geom, 4326) AS closest_geom,
	ST_SetSRID(segment, 4326) AS segment,
	ST_Length_Spheroid(segment, ’SPHEROID["WGS 84",6378137,298.257223563]’) AS LENGTH
FROM (
	/* Genere desormais le segment. Si la ligne est un retour, alors
	   la position sera inversee ce qui explique la conditionnelle
	*/
	SELECT star_ligne_itineraire.gid AS li_gid,
		p.iti_code,
		ap_ordre,
		al_id,
		prev_al_id,
		pos,
		prev_pos,
		CASE 
			WHEN prev_pos &lt ; pos
				THEN ST_Line_Substring(ST_LineMerge(star_ligne_itineraire.geom), prev_pos, pos)
			ELSE ST_Line_Substring(ST_LineMerge(star_ligne_itineraire.geom), pos, prev_pos)
			END AS segment,
		closest_geom
	FROM star_ligne_itineraire,
		(
			/* Calcul la distance entre deux points en parcourant la table
			   et en operant la difference de position entre deux points consecutifs
			*/
			SELECT iti_code,
				ap_ordre,
				al_id,
				pos,
				lag(pos, 1, 0::FLOAT) OVER (
					ORDER BY iti_code,
						ap_ordre
					) AS prev_pos,
				lag(al_id, 1) OVER (
					ORDER BY iti_code,
						ap_ordre
					) AS prev_al_id,
				closest_geom
			FROM (
				/* Projette chaque arret logique sur son itineraire et
				   trouve ainsi les points qui definiront les
				   extremites des segments.
				   Calcul une valeur entre 0 et 1 representant la 
				   position du point projete le long du segment
				*/
				SELECT o.iti_code,
					ap_ordre,
					al_id,
					ST_ClosestPoint(star_ligne_itineraire.geom, o.geom) AS closest_geom,
					ST_Line_Locate_Point(ST_LineMerge(star_ligne_itineraire.geom), ST_ClosestPoint(star_ligne_itineraire.geom, o.geom)) AS pos
				FROM star_ligne_itineraire,
					(
						/* Associe maintenant les arrets logiques aux lignes */
						SELECT iti_code,
							ap_ordre,
							al_id,
							geom
						FROM star_ap_iti,
							(
								/* Cherche la liste des identifiants des arrets logiques
								   associes aux arrets physiques
								*/
								SELECT al_id,
									ap_timeo,
									geom
								FROM star_arret_physique
								WHERE ap_timeo IN (
										SELECT ap_timeo
										FROM star_ap_iti
										)
									AND ap_type = ’mobilier’
								) AS t
						WHERE star_ap_iti.ap_timeo = t.ap_timeo
						ORDER BY iti_code,
							ap_ordre
						) AS o
				WHERE star_ligne_itineraire.iti_code = o.iti_code
					AND ST_GeometryType(ST_LineMerge(star_ligne_itineraire.geom)) = ’ST_LineString’
				ORDER BY star_ligne_itineraire.gid,
					ap_ordre
				) AS u
			) AS p
	WHERE star_ligne_itineraire.iti_code = p.iti_code
	ORDER BY star_ligne_itineraire.gid,
		ap_ordre
	) AS g
ORDER BY li_gid,
	ap_ordre ;

Notez la présence des appels à la fonction ST_LineMerge qui combine une géométrie à plusieurs lignes en une seule. Malheureusement, cette fonction ne peut pas faire de miracle sur des cas comme celui de la ligne 6.

Et voilà ! J’avais enfin une table décrivant les segments des itinéraires entre chaque arrêts les parcourant. Pfiou !

Conclusion

L’objectif de cet article est de montrer qu’une donnée brute est parfois difficile à utiliser, principalement dans le cadre d’une automatisation de son traitement. Mais l’intérêt réside aussi pour moi dans la réflexion que cela provoque. C’est comme un casse-tête géant.

Malgré cela, je dois admettre que je ne m’attendais pas à autant de complication pour un besoin qui me paraissait plutôt simple. Je ne critique pas la donnée disponible, en revanche il me semble important de voir à quel point ces écueils peuvent ralentir, voir même bloquer l’innovation qui est souvent espérée des données publiques.

Je ne suis pas certain que les agents publics doivent porter la responsabilité de fournir une donnée enrichie comme il en résulterait de mon travail ci-dessus. A mon sens, cela les ferait sortir du cadre de l’opendata. En revanche, cela laisse une porte grande ouverte à celles et ceux qui souhaiteraient se positionner sur l’aggrégation et l’enrichissement des données brutes (comme le font l’IGN ou DataPublica d’une certaine façon).

 

Remerciements

Le SIG de Rennes métropole évidemment qui a toujours été à l’écoute de mes plaintes.

Creative Commons License
Collectif Open Data Rennes by Collectif Open Data Rennes is licensed under a Creative Commons Attribution 3.0 France License.
Creative Commons License
Collectif Open Data Rennes by Collectif Open Data Rennes is licensed under a Creative Commons Attribution 3.0 France License.
Via un article de lawouach, publié le 7 novembre 2014
©© a-brest, article sous licence creative common info
flickr
Rocher signature, Brest
par TheRevSteve
Creative Commons BY-NC-SA