Programmation Fonctionnelle, TP7

Figure
Version & licenses
Creative Commons License

Programmation Fonctionnelle, TP7 : Récupérer et utiliser des données d'OpenStreetMap.

Guyslain Naves

De nombreux services en ligne permettent de récupérer facilement des données en ligne via des interfaces HTTP. Il suffit d'envoyer une requête bien formée au bon serveur pour avoir accès à une grande quantité d'information. Aujourd'hui nous récupérons des données sur OpenStreetMap. Cela va nous demander d'écrire une requête en Xml, d'envoyer la requête à un serveur, de parser la réponse (au format json), d'utiliser une représentation adaptée pour les informations récupérées et de faire un petit calcul avec (en l'occurence, on va calculer la longueur du réseau routier de Marseille).

Nous aurons besoin des librairies suivantes :

Créer la requête en XML.

Voici la requête pour récupérer l'ensemble des voies de Marseille (de l'autoroute au chemin de randonnée) :

  1. <osm-script output="json">
  2. <union>
  3. <query type="way">
  4. <has-kv k="highway"/>
  5. <bbox-query s="43.19" n="43.39" w="5.268" e="5.51"/>
  6. </query>
  7. <recurse type="way-node"/>
  8. </union>
  9. <print/>
  10. </osm-script>

Le langage de requête est spécifié ici, mais comme il ne s'agit pas du sujet du TP de l'apprendre, on va se contenter de cette requête.

On pourrait simplement mettre cette requête dans une chaîne de caractères, mais il est plus intéressant d'apprendre à générer des requêtes bien construites directement en OCaml. Pour cela il faudrait utiliser une librairie permettant de travailler avec le format Xml, telle que Xmlm ou Tyxml. On va utiliser un petit module qui est en fait une version très simplifiée de Tyxml, que vous pouvez récupérer ici.

Un fichier Xml décrit une structure arborescente. Les nœuds (par exemple : osm-script, union, ...) sont décrits par des balises de la forme <nom attributs> suivi d'une liste des enfants puis une balise fermante </nom>. Les attributs (par exemple output, type,...) de chaque nœud sont des informations associées au nœud, sous la forme de paires (identifiant, chaînes de caractère). Il peut aussi y avoir des données entre les balises. Certains balises n'ont pas d'enfant et s'écrivent alors <nom/> ou <nom attributs/>.

Le module Xml définit :

  1. type tag = string (** name of a tag (a node) *)
  2. type attribute (** an attribute that can be associated to a node *)
  3. type t (** an xml tree *)

  4. (** [key ==> value] is an attribute where [key] is associated to [value]. *)
  5. val (==>) : string -> string -> attribute

  6. (** [element name ~attributes children] is a node with
  7. tag name [name], attributes [attributes] or [[]] by default,
  8. and children [children].
  9. *)
  10. val element : tag -> ?attributes:attribute list -> t list -> t

  11. (** [data text] is a data element made of some string [text] *)
  12. val data : string -> t

  13. val pp : Format.formatter -> t -> unit

  14. val to_string : t -> string

Lisez l'interface et comprenez comment construire un arbre Xml avec. Puis créez un fichier fetchMarseilleStreets.ml dans lequel vous définissez la requête pour OpenStreetMap, de type Xml.t.

Vous pouvez compiler xml.cmo et le charger dans l'interpréteur. La valeur Xml.pp est un formatteur, vous pouvez donc faire dans l'interpréteur :

  1. #install_printer Xml.pp;;

Ainsi, lors de l'évaluation d'une valeur de type Xml.t, la chaîne de caractères correspondant au Xml s'affichera. Vous pouvez ainsi vérifier que la requête est bien formée.

Récupérer les données sur OpenStreetMap.

OpenStreetMap propose un service d'accès en lecture aux données grâce au serveur se trouvant à l'adresse http://overpass-api.de/api/, et qui est nommé l'API overpass. Le principe est de faire une requête HTTP POST sur ce serveur avec comme corps de la requête le Xml spécifiant ce que nous voulons récupérer.

Attention, l'usage de ce serveur est limité : nous ne voulons pas nous faire bannir du serveur, donc :

  • bien vérifier que la requête est correcte avant de l'envoyer (utiliser ceci),
  • ne l'envoyer qu'une seule fois.

Du coup, nous allons récupérer la réponse du serveur et la mettre dans un fichier. Nous relirons ensuite le fichier dès que nous en aurons besoin, sans que cela contribue à notre quota de requêtes sur overpass-api.

Cohttp est une librairie permettant de créer très simplement des clients ou des serveurs HTTP. Pas besoin d'avoir suivi le cours de réseau ! Nous allons spécifiquement utiliser les fonctions de Cohttp fonctionnant avec Lwt, une librairie de promesses que nous avons déjà croisée. Une valeur de type 'value Lwt.t est un calcul qui fournira éventuellement (un jour) une valeur de type 'value.

Créez un fichier fetchPostXml.ml. Commencez le fichier par :

  1. module Client = Cohttp_lwt_unix.Client
  2. module Body = Cohttp_lwt.Body

Client contient les fonctions nécessaires pour envoyer des requêtes HTTP à un serveur distant et traiter les réponses. Body contient les fonctions de manipulations du corps d'une requête HTTP. Spécifiquement, nous aurons besoin des fonctions suivantes :

  1. (** From Client *)

  2. (** [Client.post ~body server_address] is a process that will deliver
  3. the response from the server for the POST request with the given body.
  4. *)
  5. val Client.post : body:Body.t -> Uri.t -> (Cohttp.Response.t * Body.t) Lwt.t

  6. (** From Body *)
  7. val Body.of_string : string -> Body.t
  8. val Body.write_body : (string -> unit Lwt.t) -> Body.t -> unit Lwt.t

  9. (** From Lwt *)
  10. val Lwt.(>>=) : 'value Lwt.t -> ('value -> 'result Lwt.t) -> 'result Lwt.t
  11. val Lwt.fail : exn -> 'value Lwt.t
  12. val Lwt.return : 'value -> 'value Lwt.t

  13. (** From Lwt_io : Unix I/O done through Lwt *)
  14. val Output : Lwt_io.output Lwt_io.mode
  15. val Input : Lwt_io.input Lwt_io.mode
  16. val Lwt_io.open_file :
  17. mode:('kind Lwt_io.mode)
  18. -> Lwt_io.filename
  19. -> 'kind Lwt_io.channel Lwt.t
  20. val Lwt_io.write : Lwt_io.output Lwt_io.channel -> string -> unit Lwt.t
  21. val Lwt_io.close : 'kind Lwt_io.channel -> unit Lwt.t

La documentation complète est ici.

Souvenons-nous que les 'value Lwt.t s'interprêtent comme des calculs qui produiront éventuellement une valeur de type 'value. Techniquement, on s'en sert ici pour encoder les fonctions bloquantes (open_file, write, post par exemple). On peut ensuite séquencer plusieurs opérations bloquantes avec >>=.

Créer le corps de la requête POST.

Écrivez la fonction create_query_body qui étant donné un argument de type Xml.t, retourne le corps de type Body.t correspondant.

  1. val create_body_query : Xml.t -> Body.t

Récupérer le corps de la réponse.

Supposons que nous ayons la réponse, sous la forme d'une en-tête response et d'un corps body. On souhaite écrire une fonction permettant d'extraire uniquement le corps. Il suffirait donc de retourner simplement body. Mais on veut aussi vérifier que la réponse du serveur est positive. Pour cela, on observe d'abord Cohttp.Response.(response.status) : Cohttp.Code.status_code. Si c'est `OK (en majuscules), on retourne effectivement le corps. Sinon, on provoque un échec avec Lwt.fail, qui prend en argument une exception.

On définit donc une exception :

  1. exception Response_error of Cohttp.Code.status_code

Écrivez la fonction :

  1. val get_response_body : Cohttp.Response.t * Body.t -> Body.t Lwt.t

Écrire la réponse dans un fichier.

Utilisez les fonctions de Lwt_io pour, étant donné un argument de type Body.t et un nom de fichier, écrire le contenu du corps dans un fichier de ce nom. Il faut bien sûr ouvrir le fichier, écrire le corps (avec Body.write_body) et fermer le fichier.

  1. val write_into_file : filename:Lwt_io.filename -> Body.t -> unit Lwt.t

Traitement complet.

On peut maintenant écrire la fonction qui prend une adresse, un corps et un nom de fichier, envoie la requête POST au serveur, reçoit la réponse et l'écrit dans un fichier.

  1. val fetch : to_file:Lwt_io.filename -> Uri.t -> body:Body.t -> unit Lwt.t

Notez comme toutes les fonctions que nous avons utilisée ont un type de la forme 'arg -> 'result Lwt.t. C'est le type idéal pour faire des pipes avec Lwt.(>>=). Au final, le programme devrait donc être aussi simple que si nous n'avions pas utilisé Lwt.

Exécuter la requête.

Ajoutez une interface fetchPostXml.mli (exporter l'exception, create_body_query et fetch).

Revenez ensuite au fichier fetchMarseilleStreets.ml.

Créez l'adresse du serveur, de type Uri.t. Il suffit d'utiliser la fonction Uri.of_string avec comme argument la chaîne de caractères contenant l'URL du serveur : "http://overpass-api.de/api/interpreter" .

Créez une valeur main, qui utilise fetch pour créer un calcul (de type unit Lwt.t) effectuant la requête, qui produira le fichier marseille.json. Idéalement, il faudrait aussi utiliser Lwt.catch pour gérer les éventuelles erreurs (faites-le si vous avez une erreur, en ajoutant l'affichage d'un message détaillant la réponse du serveur). Il nous reste à dire à l'ordonnanceur de traiter ce calcul avec :

  1. val Lwt_main.run : result Lwt.t -> 'result

Compilez fetchMarseilleStreets.native et exécutez-le. Vérifiez que cela a bien créé un fichier contenant la réponse du serveur (le fichier doit faire de l'ordre de 25Mo ± 5Mo).

Représentation des données.

Nous allons maintenant charger le fichier et représenter les données avec des types adaptés en OCaml.

Créez un fichier osm.ml. Dans ce fichier, créez deux modules, Node pour représenter les nœuds d'OpenStreetMap (des points), Way pour représenter les routes d'OpenStreetMap (des listes de nœuds). Au début du fichier ajoutez :

  1. module Option = Base.Option
  2. module List = Base.List
  3. module IdMap = Map.Make(Base.Int)
  4. module Json = Yojson.Basic.Util

Node

Chaque nœud possède :

  • un identifiant "id" entier,
  • une longitude "lon" flottante,
  • une latitude "lat" flottante,
  • peut-être aussi des "tags" : une liste de paires (clé, valeur). La clé est une chaîne de caractères, la valeur est de type Yojson.Basic.json

Ajoutez dans Node un type t pour représenter les nœuds.

On veut maintenant ajouter une fonction convertissant une valeur de type Yojson.Basic.json en un nœud. Pour rappel, voici ce type :

  1. type json = [
  2. | `Null
  3. | `Bool of bool
  4. | `Int of int
  5. | `Float of float
  6. | `String of string
  7. | `Assoc of (string * json) list
  8. | `List of json list
  9. ]

Un nœud est représentée par une liste d'association `Assoc, possédant les éléments ayant les clés "type" , "id" , "lat" , "lon" et "tags" , et tel que "type" est associé à "node" . Par exemple :

  1. {
  2. "type": "node",
  3. "id": 30084297,
  4. "lat": 43.2685493,
  5. "lon": 5.3706296,
  6. "tags": {
  7. "bus": "yes",
  8. "highway": "bus_stop",
  9. "name": "Corniche Talabot",
  10. "network": "RTM",
  11. "operator": "Régie des Transports Marseillais",
  12. "public_transport": "stop_position"
  13. }
  14. }

Créez une fonction prenant une valeur de type json et retournant un nœud si elle représente un nœud, rien sinon :

  1. val of_json : Yojson.Basic.json -> t option

Comme le champ tags est optionnel, on utilisera Json.to_option Json.to_assoc pour traiter le contenu de ce champ (ce qui nous fournit une option sur le contenu du champ).

Enfin, ajoutez la fonction suivante qui associe à chaque nœud une position (adaptez-la selon votre type des nœuds) :

  1. let to_vector { longitude; lattitude } =
  2. let earth_radius = 6.371e6 in
  3. let azimuthal_angle = longitude *. Gg.Float.pi /. 180. in
  4. let polar_angle = (90. -. lattitude) *. Gg.Float.pi /. 180. in
  5. Gg.V3.v earth_radius azimuthal_angle polar_angle
  6. |> Gg.V3.of_spherical

Way

Chaque route possède :

  • un identifiant entier,
  • une liste de nœuds,
  • peut-être aussi des "tags" avec chacun une paire (clé, valeur). La clé est une chaîne de caractères, la valeur est de type Yojson.Basic.json

Ajoutez un type pour représenter les routes, et une fonction de comparaison.

Pour créer la liste des nœuds d'une route, il faut pouvoir retrouver un nœud à partir d'un identifiant entier. Pour cela, on utilise une structure de dictionnaire. Le module IdMap que nous avons défini en haut du fichier est un dictionnaire sur des clés entières. On peut donc utiliser les fonctions :

  1. val IdMap.empty : 'value IdMap.t
  2. val IdMap.is_empty : 'value IdMap.t -> bool
  3. val IdMap.add : int -> 'value -> 'value IdMap.t -> 'value IdMap.t
  4. val IdMap.mem : int -> 'value IdMap.t -> bool
  5. val IdMap.find : int -> 'value IdMap.t -> 'value

Mieux vaut redéfinir find avec des options :

  1. let find id nodes =
  2. if IdMap.mem id nodes then Some (IdMap.find id nodes)
  3. else None

Créez une fonction convertissant une liste json d'entiers en une liste de nœuds. La fonction prend en argument le dictionnaire de tous les nœuds. On pourra utiliser List.filter_opt : 'elt option list -> 'elt list.

  1. val nodes_of_ids : Node.t IdMap.t -> json -> Node.t list

Les routes ressemblent à ceci en json :

  1. {
  2. "type": "way",
  3. "id": 350306384,
  4. "nodes": [
  5. 262629740,
  6. 262629742,
  7. 262629743,
  8. 262629744
  9. ],
  10. "tags": {
  11. "highway": "steps",
  12. "source": "cadastre-dgi-fr source : Direction Générale des Impôts - Cadastre. Mise à jour : 2015"
  13. }
  14. }

Créez une fonction de conversion d'une valeur de type json vers Way.t option.

  1. val of_json : Node.t IdMap.t -> Yojson.Basic.json -> t

Lire le fichier json et faire un calcul.

On peut lire le fichier json avec la fonction :

  1. val Yojson.Basic.from_file : string -> Yojson.Basic.json

Le json contient une liste d'association, ayant un membre "elements" associé à une liste. Chaque élément de la liste est un nœud ou un route (ou une relation).

Créez le dictionnaire des nœuds définis dans le fichier. Créez la liste des routes définies dans le fichier.

  1. val create_nodes_map : Yojson.Basic.json -> Osm.Node.t Osm.IdMap.t
  2. val create_ways_list :
  3. Yojson.Basic.json -> Osm.Node.t Osm.IdMap.t -> Osm.Way.t list

Créez une fonction calculant la longueur d'une route :

  1. val get_way_length : Osm.Node.t Osm.IdMap.t -> Osm.Way.t -> float

Puis calculez la longueur totale des routes de Marseille.

À vous de jouer...

  • Filtrez les routes bétonnées. Pour savoir comment repérer le type de route, consultez cette page.
  • Créez un fichier svg représentant les routes de Marseille. Il faudra convertir les coordonnées 3D en coordonnée 2D pour cela.
  • Utilisez OpenStreetMap pour récupérer d'autres informations, pour écrire un programme recherchant la pharmacie la plus proche, etc.