SiCen Mobile : utilisation de formulaires ODK pour alimenter notre base de données d'observations

Cette page est le résultat d'un travail mené en commun, en mars 2015 par 4 géomaticiens et informaticiens des CEN Rhône-Alpes (Rémy Clément, Guillaume Costes et Laurent Poulin) et Languedoc-Roussillon (Mathieu Bossaert)

Elle a été actualisée le 17 mai 2018.

OpenDataKit est une suite d'outils libres dédiée à la collecte de données sur terminaux mobiles Androïd.

D'une relative simplicité de mise en oeuvre, la solution permet facilement de décrire et créer des formulaires correspondant à nos besoins. Une fois les données récupérées, il est simple de les intégrer à notre base de données en place.

Nous allons donc passer en revue l'installation des outils de la suite, la définition du formulaire avec XLSForm, et la ventilation des données récoltées dans notre base de données "métier", SiCen.

Présentation générale

ODK est un générateur de formulaires Open Source pour Android. Il permet de collecter des données en mode déconnecté. Les données sont envoyées quand une connexion est disponible, ou par upload de fichiers.

Les formulaires sont créés de manière simple, en utilisant un outil dédié (ODKBuild) ou en les décrivant dans un fichier excel avec le standard XLSForm

Tous les types de données sont disponibles et disposent de "widgets" adaptés : dates, textes, nombres, booléens, geo. Tous les médias que peut créer votre appareil androïd peuvent être attachés à l'observation eux aussi : son, vidéo, photo.

Il est possible d'interroger de longs référentiels (ex. TAXREF), fournis en csv avec le formulaire.

Les outils de la suite

Build

Application en ligne qui permet la création de formulaires grâce à une interface : http://build.opendatakit.org/

XLSForm

"XLSForm est une norme de formulaires créée pour aider à simplifier la création de formulaires dans Excel". C'est dans un tableur que nous allons décrire de manière simple la strcuture et la logique du formulaire.

Collect

C'est l'application Androïd à proprement parler : https://opendatakit.org/use/collect/

Installée depuis le dépot des applications de google, elle va se connecter au serveur "Aggregate", récupérer et proposer au téléchargement la liste des formulaires disponibles. Puis envoyer à "Aggregate" les données collectées.

Aggregate

C'est le serveur, le chef d'ochestre. Il fournit les formulaires, et consolide et diffuse les données reçues : https://opendatakit.org/use/aggregate/

Installé au sein d'un réseau interne, Aggregate peut-être associé à MySQL ou PostgreSQL. Dans notre cas, il sera asoocié à PostGIS et stockera ses données dans le schéma "odk"de notre base de données "SiCen".

Mise en œuvre au sein de l'intranet

Installation d'aggregate

Elle peut se faire simplement en utilisant une machine virtuelle, diffusée à prix libre depuis cet été : https://gum.co/odk-aggregate-vm

Dans notre cas, nous allons procéder au déploiement d'une application java sur un serveur Tomcat.

Nous considérerons par la suite que tomcat est installé :  https://opendatakit.org/use/aggregate/tomcat-install/

 

Étapes

On télécharge « l'installeur », qui n'en est pas un : https://opendatakit.org/downloads/

Il s'agit en fait d'un exécutable qui va générer l'archive .war et le script SQL de création de la base de données (base de données, utilisateur odk et schema), conformément aux paramètres renseignés. Les tables sont créées au lancement du war et à l'ajout de nouveaux formulaires

On exécute sur le serveur de base de données les commandes SQL générées. Elles vont permettre de créer les tables et autres objets de base de données nécessaires à Aggregate pour fonctionner.

Puis on déploie l'applicatiopn depuis l'interface de tomcat-manager ou en déplaçant le war dans le dossier webapps de tomcat

Mise en œuvre du formulaire

Nous avions le projet de réaliser une application mobile complète dédiée à l’outil WEB SiCen, permettant de saisir nos observations sur un terminal Android pour les retrouver dans l'interface web de SICEN, sans intervention de l'observateur, autre que d'envoyer les données du formulaire au serveur.

Besoin simple

Il nous faut collecter des données d’observations d'espèces, localisées. Elles sont collectées dans le cadre d'une étude et selon un protocole particuliers

-> une étude, un protocole, des localités sur lesquelles on observe des espèces

Un formulaire GeoODK

→ conçu avec XLSForm

Le fichier excel décrivant le formulaire ainsi que les ressources csv nécessaires à son fonctionnement sont présents dans l'archive ci-jointe : demo_aten.zip
Trucs et astuces

    Pour éviter la demande d’ajout de groupe, lors des « repeat » : Supprimer dans le xml le jr template il est présent au début du xml dans la liste des balises champs du début
    Possibilité de cumuler la fonction quick + search dans la colonne « appearance » du fichier excel des formulaires avec la fonction quick search(...)
    Le widget date avec calendrier n’est pas adapté : mettre dans la colonne « appearance » no-calendar
    Génération des noms de formulaire automatiquement en rajoutant une colonne « instance_name » dans l’onglet « settings » du fichier excel. En utilisant un champ calculé.

Des référentiels en csv générés depuis la base de données

ODK permet désormais d'associer au formulaire de grosses listes de références dans des fichiers csv. Elles seront diffusées à ODKCollect avec le formulaire. Ce dernier les transformera en base de données locale sqlite.

La fonction search() permet de filtrer les entrées proposées, à celle contenant la chaîne de caractèressaisie par l'utilisateur. Nous utiliserons cette possibilité pour la gestion des listes de choix relatives aux :

  • taxons observés
  • observateurs et structures
  • études et protocoles

Voici une démonstration du résultat

 

Création du fond de carte

L’application permet non seulement de créer tout type d’objet mais également d’embarquer sur le terminal des fonds cartographiques en local (pas de réseau nécessaire).

Les fonds doivent être au format mbtiles. Ils peuvent être généré avec TileMill, comme décrit dans la doc de GeoODK : http://geoodk.com/mbtiles_howto.php, ou d'autres outils.

Dans notre cas, nous utiliserons MOBAC parceque nous l'utilisons déjà. Par contre, le mbtile produit par MOBAC nécessitera une petite mabnipulation :
Ouvrir le fichier avec un sqlite manager et exécuter la requête suivante :

 CREATE VIEW images as SELECT ROWID, "tile_data", tile_row as "tile_id" FROM "tiles" ORDER BY ROWID

 

Le fichier MBTiles correspondant aux fonds cartographiques doit ensuite être placé dans un sous dossier du dossier OfflineLayers : \OfflineLayers\Sous_Dossier_contenant_Mbtiles.

 

Ventilation des données dans la base «métier»

 

Création d'une vue qui met les données au format propre à notre table de destination (saisie.saisi_obsevration)

/*
Mise à jour d'aout 2017 . Création d'une fonction générique pour générer la géomatrie
Pour faciliter l’écriture des requêtes d'exploitation des données, nous avons créé une fonction qui transforme les différents types de géométrie stockées dans aggegate, dans un format intelligible par PostGIS : 
 - /save/https://github.com/opendatakit/collect/issues/1123#issuecomment-307546757
 - /save/https://forum.opendatakit.org/t/geo-fields-should-be-stored-in-wkt-format-well-known-text/7048
*/
 
CREATE OR REPLACE FUNCTION odk.geom_from_odk_to_postgis(
        LONG numeric, 
        LAT numeric, 
        GEOTRACE TEXT, 
        GEOSHAPE TEXT
)
RETURNS geometry(GEOMETRY,4326) AS
$$
WITH geom_from_odk AS 
(
        SELECT trim(COALESCE(NULLIF(trim(concat(LAT,' ',LONG),';'),' '),
        GEOTRACE,
        GEOSHAPE),';') as geom_odk
),
node_array AS (
        SELECT geom_odk, unnest(string_to_array(btrim(geom_odk::text, ';'::text), ';'::text)) AS unnest,
        array_length(string_to_array(btrim(geom_odk::text, ';'::text), ';'::text), 1) AS nb_points,
        generate_series(1, array_length(string_to_array(btrim(geom_odk::text, ';'::text), ';'::text), 1)) AS ordre,
        split_part(unnest(string_to_array(btrim(geom_odk::text, ';'::text), ';'::text)), ' '::text, 1)::numeric AS LAT,
        split_part(unnest(string_to_array(btrim(geom_odk::text, ';'::text), ';'::text)), ' '::text, 2)::numeric AS long
        FROM geom_from_odk
)--, geom AS (
SELECT 
CASE
WHEN node_array.nb_points = 1 THEN st_union(st_setsrid(st_makepoint(node_array.long::double precision, node_array.lat::double precision), 4326))
WHEN st_isclosed(st_makeline(array_agg(st_setsrid(st_makepoint(node_array.long::double precision, node_array.lat::double precision), 4326) ORDER BY node_array.ordre))) IS FALSE THEN st_makeline(array_agg(st_setsrid(st_makepoint(node_array.long::double precision, node_array.lat::double precision), 4326) ORDER BY node_array.ordre))
WHEN st_isclosed(st_makeline(array_agg(st_setsrid(st_makepoint(node_array.long::double precision, node_array.lat::double precision), 4326) ORDER BY node_array.ordre))) AND node_array.nb_points > 3 THEN st_makepolygon(st_makeline(array_agg(st_setsrid(st_makepoint(node_array.long::double precision, node_array.lat::double precision), 4326) ORDER BY node_array.ordre)))
-- ELSE the polygon is invalid because made of 3 points, 1st and 3rd are the same so we create a linestring
ELSE st_makeline(array_agg(st_setsrid(st_makepoint(node_array.long::double precision, node_array.lat::double precision), 4326) ORDER BY node_array.ordre))
END AS geom
FROM node_array
GROUP BY node_array.nb_points;
$$
  LANGUAGE sql VOLATILE
  COST 100;
 
 
/*
Création de la vue de mise au format des données
*/
 
CREATE OR REPLACE VIEW odk.obs_mobile_demo_aten AS 
 WITH geom AS (
	SELECT "_URI", 
	st_transform(
		odk.geom_from_odk_to_postgis(
			"GPS_LNG",
			"GPS_LAT",
			''::text, -- Chaîne vide si pas de georace
			"SHAPE_TEXT"
		),2154 -- la function pourrait être améliorée pour produire la géomatrie dans la projection attendue
	) AS geometrie		
	FROM odk."DEMO_ATEN_NOUVELLE_LOCALITE"
        )
 SELECT core."_URI", core."RELEVE_DATE_OBS"::date AS date_obs, taxref.regne, 
    taxref.nom_complet, 
    btrim(split_part(obs."LB_CD_NOM_LATIN"::text, '{'::text, 2), '}'::text) AS cd_nom, 
        CASE
            WHEN taxref.regne::text = 'Animalia'::text THEN obs."CARAC_OBSERVATION_FAUNE_FAUNE_EFFECTIF"
            ELSE obs."CARAC_OBSERVATION_FLORE_FLORE_EFFECTIF"
        END AS effectif, 
    obs."CARAC_OBSERVATION_FLORE_EFFECTIF_TEXTUEL" AS effectif_text, 
    obs."CARAC_OBSERVATION_FAUNE_TYPE_EFFECTIF" AS type_effectif, 
    obs."CARAC_OBSERVATION_FLORE_PHENOLOGIE" AS phenologie, 
    obs."_URI" AS id_waypoint, 
    COALESCE(localite."GPS_LNG"::double precision, st_x(st_transform(st_centroid(geom.geometrie), 4326))) AS long, 
    COALESCE(localite."GPS_LAT"::double precision, st_y(st_transform(st_centroid(geom.geometrie), 4326))) AS lat, 
        CASE
            WHEN taxref.regne::text = 'Animalia'::text THEN obs."CARAC_OBSERVATION_FAUNE_LOCAL_RQS"
            WHEN taxref.regne::text = 'Plantae'::text THEN obs."CARAC_OBSERVATION_FLORE_LOCAL_RQS"
            ELSE NULL::character varying
        END AS remarque_localisation, 
    btrim(split_part(core."RELEVE_CODE_OBSERVATEUR"::text, '{'::text, 2), '}'::text) AS observateurs, 
    btrim(split_part(core."RELEVE_CODE_OBSERVATEUR"::text, '{'::text, 2), '}'::text)::integer AS numerisateur, 
    '2'::text AS structure, 
    pg_catalog.concat('id sicen_mobil ', obs."_URI", 
        CASE
            WHEN localite."ORIGINE_LOCALISATION"::text = 'gps'::text THEN pg_catalog.concat('. Précision GPS : ', localite."GPS_ACC", '. ')
            ELSE NULL::text
        END, 
        CASE
            WHEN taxref.regne::text = 'Animalia'::text THEN obs."CARAC_OBSERVATION_FAUNE_OBS_RQS"
            WHEN taxref.regne::text = 'Plantae'::text THEN obs."CARAC_OBSERVATION_FLORE_OBS_RQS"
            ELSE NULL::character varying
        END) AS remarque_obs, 
    commune.insee_com, 'GPS'::saisie.enum_precision AS "precision", 
    'à valider'::saisie.enum_statut_validation AS statut_validation, 
    btrim(split_part(core."RELEVE_ID_ETUDE"::text, '{'::text, 2), '}'::text)::integer AS id_etude, 
    btrim(split_part(core."RELEVE_ID_PROTOCOLE"::text, '{'::text, 2), '}'::text)::integer AS id_protocole, 
    geom.geometrie::geometry(Geometry,2154) AS geometrie
   FROM odk."DEMO_ATEN_CORE" core
   JOIN odk."DEMO_ATEN_NOUVELLE_LOCALITE" localite ON localite."_PARENT_AURI"::text = core."_URI"::text
   JOIN odk."DEMO_ATEN_NOUVELLE_OBSERVATION" obs ON localite."_URI"::text = obs."_PARENT_AURI"::text
   JOIN geom ON geom."_URI"::text = localite."_URI"::text
   JOIN inpn.taxref ON taxref.cd_nom::text = btrim(split_part(obs."LB_CD_NOM_LATIN"::text, '{'::text, 2), '}'::text)
   LEFT JOIN ign_geofla.commune ON st_within(st_centroid(geom.geometrie), commune.geometrie)
   LEFT JOIN saisie.suivi_saisie_observation ON suivi_saisie_observation.id_waypoint = obs."_URI"::text
  WHERE suivi_saisie_observation.id_waypoint IS NULL
  ORDER BY localite."_ORDINAL_NUMBER", obs."_ORDINAL_NUMBER";
view raw
ventilation_GeoODK_vue.sql hosted withby GitHub
Création de la fonction qui sera appelé par le trigger
CREATE OR REPLACE FUNCTION odk.alimente_saisie_observation_demo_aten()
  RETURNS trigger AS
$BODY$ declare
BEGIN
INSERT INTO saisie.saisie_observation(
            date_obs, regne, nom_complet, cd_nom, effectif, effectif_textuel, type_effectif, phenologie, id_waypoint, 
            longitude, latitude, localisation, observateur, numerisateur, 
            structure, remarque_obs, code_insee, 
            "precision", statut_validation, id_etude, id_protocole, 
            geometrie)
SELECT date_obs, regne, nom_complet, cd_nom, effectif, effectif_text, 
       type_effectif, phenologie, id_waypoint, long, lat, remarque_localisation, observateurs, 
       numerisateur, structure, remarque_obs, insee_com, "precision", 
       statut_validation, id_etude, id_protocole, geometrie
  FROM odk.obs_mobile_demo_aten
  WHERE obs_mobile_demo_aten."_URI"  = NEW."_URI";
RETURN NULL; 
END; $BODY$
LANGUAGE plpgsql VOLATILE SECURITY DEFINER;

Création de la fonction qui sera appelé par le trigger

CREATE OR REPLACE FUNCTION odk.alimente_saisie_observation_demo_aten()
  RETURNS trigger AS
$BODY$ declare
BEGIN
INSERT INTO saisie.saisie_observation(
            date_obs, regne, nom_complet, cd_nom, effectif, effectif_textuel, type_effectif, phenologie, id_waypoint, 
            longitude, latitude, localisation, observateur, numerisateur, 
            structure, remarque_obs, code_insee, 
            "precision", statut_validation, id_etude, id_protocole, 
            geometrie)
SELECT date_obs, regne, nom_complet, cd_nom, effectif, effectif_text, 
       type_effectif, phenologie, id_waypoint, long, lat, remarque_localisation, observateurs, 
       numerisateur, structure, remarque_obs, insee_com, "precision", 
       statut_validation, id_etude, id_protocole, geometrie
  FROM odk.obs_mobile_demo_aten
  WHERE obs_mobile_demo_aten."_URI"  = NEW."_URI";
RETURN NULL; 
END; $BODY$
LANGUAGE plpgsql VOLATILE SECURITY DEFINER;

Création du trigger

 

Après insertion d'un enregistrement dans la table « nom_form_CORE »

CREATE TRIGGER demo_aten_alimente_saisie_obs
AFTER INSERT
ON odk."DEMO_ATEN_CORE"
FOR EACH ROW
EXECUTE PROCEDURE odk.alimente_saisie_observation_demo_aten();

→ insère la nouvelle obs dans la table « saisie.saisie_observation »

 


 

 

Voilà donc nos données envoyées au serveur Aggregate, directement visibles et modifiables dans SiCen !

 

Conclusion / Bilan

 

Améliorations récentes

 

→ utilisation de gros référentiels + widget cartographique

 

Facilité de mise en œuvre de la solution

 

→ appli Android + déploiement WAR / Machine virtuelle

 

Souplesse / facilité de création de formulaires de saisie

 

→ Par des collègues non géomaticien / montée en compétence rapide

 

Intégration aisée au SI en place dans la structure

 

→ En utilisant les outils standards de notre base de données (vues et triggers)

 

  En lien avec cet article

 

 

Tags: 

Les contenus des sites sig.cenlr.org et si.cenlr.org sont mis à disposition selon
les termes de la licence Creative Commons Attribution 3.0 France

Licence Creative Commons