R et Shiny peuvent-ils faire de moi un meilleur pêcheur ? Partie 1

Créer une application Shiny pour stocker mes données de pêche

R
Shiny
Web scraping
Auteur·rice

Aurélien Callens

Date de publication

12 septembre 2020

ℹ️ Note: Lorsque j’ai développé cette application, j’étais débutant en développement web et en gestion des données. J’ai choisi Shiny car c’était une solution simple pour moi à l’époque.
Avec le recul, si je devais refaire ce projet aujourd’hui, j’opterais plutôt pour une application Django avec une base de données dédiée, ce qui offrirait plus de flexibilité et de robustesse.

Mon passe-temps favori, en plus de R bien sûr, est la pêche. La plupart du temps, je pêche le bar (Dicentrarchus labrax) dans les estuaires. Le bar est un prédateur qui a un large éventail de proies : crabes, lançons, crevettes, gambas et autres poissons. Pour pêcher le bar, je n’utilise pas d’appâts vivants, je préfère utiliser des leurres artificiels qui imitent une proie spécifique.

En théorie, attraper un poisson est assez simple :

  1. Utiliser un leurre qui imite la proie actuelle du bar.

  2. Animer le leurre dans une zone où les poissons sont actifs.

  3. Attraper un très gros poisson !

En pratique, c’est une autre histoire ! En effet, l’activité alimentaire, la position du bar dans l’estuaire et ses proies varient en fonction de plusieurs paramètres :

Comme vous l’avez compris, de nombreux paramètres peuvent potentiellement influencer les résultats de mes sessions de pêche. C’est pourquoi j’ai décidé de créer une application Shiny pour augmenter le nombre et la taille des poissons capturés durant mes sessions. Pour atteindre cet objectif, je dois mieux comprendre l’activité, la position et les proies du bar en fonction des paramètres décrits ci-dessus.

Exigences de mon application

  • Elle doit stocker les données de mes sessions de pêche :
Informations nécessaires Description des variables Source des données
Temps Heure à laquelle un poisson est capturé, durée écoulée depuis le début de la session R
Prise Espèce et taille du poisson capturé Géolocalisation via smartphone ?
Leurres Type, longueur, couleur du leurre utilisé API météo
  • Elle doit enregistrer les données sur mes prises et les leurres artificiels utilisés :
Informations nécessaires Description des variables Source des données
Temps Heure à laquelle un poisson est capturé, durée écoulée depuis le début de la session R
Prise Espèce et taille du poisson capturé Saisie utilisateur
Leurres Type, longueur, couleur du leurre utilisé Saisie utilisateur
  • Elle doit être adaptée aux petits écrans, car je l’utiliserai toujours sur mon téléphone.

  • Elle doit rester gratuite.

Collecte des données

Récupération de ma position GPS

Ma position GPS est collectée grâce à un peu de code Javascript intégré dans l’en-tête de l’application Shiny. Ce code a été développé par AugusT et est disponible sur son dépôt GitHub.

API météo

Pour les données météorologiques, j’ai trouvé une API gratuite appelée Dark Sky. J’ai développé une fonction qui prend en entrée les coordonnées d’un lieu ainsi que la clé utilisateur de l’API et retourne les conditions météorologiques actuelles sous forme de dataframe :

library(httr)
library(jsonlite)
library(tidyverse)
library(rvest)

weather <- function(x, API_key){
  url <- paste0("https://api.darksky.net/forecast/",API_key,
                "/", x[1], ",", x[2],
                "?units=ca&exclude=hourly,alerts,flags")
  
  rep <- GET(url)
  
  table <- fromJSON(content(rep, "text"))
  
  current.weather.info <- with(table,
                               data.frame(Air_temp = currently$temperature,
                                     Weather = currently$summary,
                                     Atm_pres = currently$pressure,
                                     Wind_str = currently$windSpeed,
                                     Wind_dir = currently$windBearing,
                                     Cloud_cover = currently$cloudCover,
                                     PrecipProb = currently$precipProbability,
                                     PrecipInt = currently$precipIntensity,  
                                     Moon = daily$data$moonPhase[1]))
  return(current.weather.info)
}

Web scraping des données de marée

J’ai créé une fonction pour récupérer des informations sur les marées à partir d’un site web français. La fonction suivante ne prend aucun argument et retourne le niveau d’eau actuel, l’état de la marée (montante ou descendante) ainsi que le temps écoulé depuis le dernier pic de marée pour le lieu où je pêche.

tide <- function(){
  
  # Set the current time and time zone 
  Sys.setenv(TZ="Europe/Paris")
  time <- as.POSIXct(Sys.time())
  url <- "https://services.data.shom.fr/hdm/vignette/grande/BOUCAU-BAYONNE?locale=en"
  
  # Read the web page that contains the tide data 
  text <- url %>% 
    read_html() %>%
    html_text()
  
  # Clean the html data to get a dataframe  with two cols Time and water level: 

  text <- as.character(sub(".*var data = *(.*?) *\\;.*", "\\1", text))
  text <- unlist(str_split( substr(text, 1, nchar(text)-2), "\\],"))
  tidy_df <- data.frame(hour=NA,Water=NA)
  
  for(i in 1:length(text)){
    text_dat <- unlist(str_split(text[i], '"'))[c(2,3)]
    text_dat[1] <- substr(text_dat[1], 1, nchar(text_dat[1])-1)
    text_dat[2] <- as.numeric(substr(text_dat[2], 2, nchar(text_dat[2])))
    tidy_df[i,] <- text_dat
  }
  
  tidy_df$hour <- as.POSIXct(paste(format(Sys.time(),"%Y-%m-%d"), tidy_df$hour))
  
  # Some lines to get the tide status (going down or up) : 
  
  n_closest <- which(abs(tidy_df$hour - time) == min(abs(tidy_df$hour - time)))
  
  water_level <- as.numeric(tidy_df[n_closest, 2])
  
  all_decrea <- all(tidy_df$Water[(n_closest-6):(n_closest+6)] ==
                      cummin(tidy_df$Water[(n_closest-6):(n_closest+6)] ))
  
  all_increa <- all(tidy_df$Water[(n_closest-6):(n_closest+6)] ==
                      cummax(tidy_df$Water[(n_closest-6):(n_closest+6)] ))
  
  maree <- ifelse(all_decrea, "Down", ifelse(all_increa, "Up", "Dead"))
  
  
  # Compute time since the last peak :
  
  last_peak <- max(cumsum(rle(diff(as.numeric(tidy_df$Water), lag = 2) > 0)$lengths)
                   [cumsum(rle(diff(as.numeric(tidy_df$Water), lag = 2) >0)$lengths) < n_closest])
  
  
  time_after <- as.numeric(difftime(tidy_df$hour[n_closest], tidy_df$hour[last_peak], units = "mins"))
  
  
  # Return the list with the results :
  
  return(list(Water_level = water_level,
              Maree = maree,
              Time_peak = time_after))
  
}

L’application Shiny

Le principal problème que j’ai rencontré lors du développement de cette application était le stockage des données. Shinyapps.io héberge gratuitement votre application Shiny, mais j’ai rencontré des problèmes lorsque j’ai utilisé l’application pour modifier les fichiers CSV.
La solution que j’ai trouvée a été de stocker les données sur mon compte Dropbox. Vous pouvez trouver ici plus de détails sur le sujet ainsi que des solutions alternatives. J’ai utilisé le package rdrop2 pour accéder et modifier les données via l’application Shiny.

Voici les principales étapes de cette application :

  1. Au démarrage de l’application, un fichier CSV stocké sur mon Dropbox est lu afin de vérifier si une session de pêche est en cours ou non. Si ce n’est pas le cas, l’utilisateur peut démarrer une session de pêche.

  2. Lors du démarrage d’une nouvelle session, une ligne contenant les coordonnées, les conditions météorologiques et les conditions de marée est ajoutée au fichier CSV mentionné précédemment.

  3. Si un poisson est pêché, l’utilisateur peut remplir un formulaire pour enregistrer les données dans un second fichier CSV. Ce fichier contient : l’heure, l’espèce et la longueur du poisson ainsi que des informations sur le leurre utilisé (type, couleur, longueur).

  4. L’utilisateur peut mettre fin à la session de pêche en appuyant sur un bouton. Cela enregistre l’heure de fin, les conditions météorologiques et les conditions de marée dans le premier fichier CSV.

Un schéma simplifié est présenté ci-dessous :

Simplified workflow of the application

Côté interface utilisateur (UI)

L’interface utilisateur de l’application est construite en utilisant le package miniUI. Ce package permet aux utilisateurs de R de développer des applications Shiny adaptées aux petits écrans.

# Load libraries 
library(shiny)
library(shinyWidgets)
library(googlesheets)
library(miniUI)
library(leaflet)
library(rdrop2)
Sys.setenv(TZ="Europe/Paris")

#Import the functions for weather API and webscrapping 
suppressMessages(source("api_functions.R"))


# Load the dropbox token : 
token <<- readRDS("token.rds")

# Minipage for small screens
ui <- miniPage(
  # Javascript that give user location (input$lat,input$long)
  tags$script('$(document).ready(function () {
                           navigator.geolocation.getCurrentPosition(onSuccess, onError);
                           
                           function onError (err) {
                           Shiny.onInputChange("geolocation", false);
                           }
                           
                           function onSuccess (position) {
                           setTimeout(function () {
                           var coords = position.coords;
                           console.log(coords.latitude + ", " + coords.longitude);
                           Shiny.onInputChange("geolocation", true);
                           Shiny.onInputChange("lat", coords.latitude);
                           Shiny.onInputChange("long", coords.longitude);
                           }, 1100)
                           }
                           });'),
  
  gadgetTitleBar("Catch them all", left = NULL, right = NULL),
  
  miniTabstripPanel(
    #First panel depends if a fishing session is started or not 
    miniTabPanel("Session", icon = icon("sliders"),
                 miniContentPanel(uiOutput("UI_sess", align = "center"),
                                  uiOutput("UI", align = "center"))
    ),
    # Second panel displays the location of the previous fishing session with the number of fish caught 
    miniTabPanel("Map", icon = icon("map-o"),
                 miniContentPanel(scrollable = FALSE,padding = 0,
                                  div(style="text-align:center",
                                      prettyRadioButtons("radio", inline = TRUE, label = "",
                                                         choices = list("3 dernières sessions" = 1,
                                                                        "3 Meilleures Sessions" = 2,
                                                                        "Tout afficher" = 3), 
                                                         selected = 1)),
                                  leafletOutput("map", height = "93%")
                 ))
  )
  
)

Côté serveur

Le côté serveur est principalement composé de fonctions observeEvent. L’utilité de chaque observeEvent est indiquée dans le script sous forme de commentaires.

server <- function(input, output, session){
  source("api_functions.R")
  
  # Read the csv file containing information about fishing session. If a session is running,
  # display the UI that allows the user to input data about the fish caught. If a session is not started,
  # display a button to start the session.
  
  observeEvent(input$go ,{
    
    dat <<- drop_read_csv("/app_peche/session.csv", header = T, stringsAsFactors = F, dtoken = token) 
    
    output$UI<- renderUI({
      tagList(
        if(rev(dat$Status)[1] == "end"){
          actionButton("go","Start session")}
        else{
          actionButton("go","End session") 
        }
      )
    })
    
    output$UI_sess<- renderUI({
      if(rev(dat$Status)[1] == "end"){
        tagList(textInput("comments", label = h3("Commentaires"), value = "NA"))
      }else{
        input$catch
        
        tagList(
          selectInput("species", label = h3("Espèces"), 
                      choices = list("Bar" = "bar", 
                                     "Bar moucheté" = "bar_m", 
                                     "Alose" = "alose",
                                     "Alose Feinte" = "alose_f",
                                     "Maquereau" = "maquereau", 
                                     "Chinchard" = "chinchard"), selected = "bar"),
          
          sliderInput("length",label = h3("Taille du poisson"),value=25,min=0,max=80, step=1),
          
          selectInput("lure", label = h3("Type de leurre"), 
                      choices = list("Shad" = "shad",
                                     "Slug" = "slug",
                                     "Jerkbait" = "jerkbait",
                                     "Casting jig" = "jig",
                                     "Topwater" = "topwater"), selectize = FALSE),
          
          selectInput("color_lure", label = h3("Couleur du leurre"), 
                      choices = list("Naturel" = "naturel",
                                     "Sombre" = "sombre",
                                     "Clair" = "clair",
                                     "Flashy" = "flashy" ), selectize = FALSE),
          
          selectInput("length_lure", label = h3("Taille du leurre"), 
                      choices = list("Petit" = "petit",
                                     "Moyen" = "moyen",
                                     "Grand" = "grand"), selectize = FALSE),
          
          actionButton("catch","Rajoutez cette capture aux stats!"),
          
          textInput("comments1", label = h3("Commentaire avant la fin ?"), value = "NA")
          
          
        )
        
        
      }
      
    })  
    
    
  }, ignoreNULL = F)
  
  #If the button is pushed, create the line to be added in the csv file. 
  
  observeEvent(input$go,{
    
    #Tide + geoloc + Weather
    c_tide <- unlist(tide())
    geoloc <- c(input$lat,input$long)
    current.weather.info <- weather(geoloc) 
    
    # Two outcomes depending if the session starts or ends. This gives the possibility 
    # to the user to add a comment before starting the session or after ending the session
    
    if(rev(dat$Status)[1] == "end"){
      
      n_ses <- c(rev(dat$Session)[1]+1)
      stat_ses <- c("beg")
      time_beg <- as.character(as.POSIXct(Sys.time()))
      comment <- input$comments
      dat.f <- data.frame(n_ses, stat_ses, time_beg ,geoloc[2], geoloc[1], current.weather.info, c_tide[1], c_tide[2], c_tide[3], comment)
      names(dat.f)<-names(dat)
      a <- rbind(dat,dat.f)
      
    }else{
      
      n_ses <- c(rev(dat$Session)[1])
      stat_ses <- c("end")
      time_beg <- as.character(as.POSIXct(Sys.time()))
      comment1 <- input$comments1
      dat.f<- data.frame(n_ses, stat_ses, time_beg ,geoloc[2], geoloc[1], current.weather.info, c_tide[1], c_tide[2], c_tide[3], comment1)
      names(dat.f)<-names(dat)
      a <- rbind(dat,dat.f)
    }
    
    # Write csv in temporary files of shiny server 
    write_csv(as.data.frame(a), "session.csv")
    
    # Upload it to dropbox account 
    drop_upload("session.csv", path = "App_peche", mode = "overwrite", dtoken = token)
  })
  
  
  # Add a line to the catch csv file whenever a fish is caught
  observeEvent(input$catch,{
    caugth <- drop_read_csv("/app_peche/catch.csv", header = T, stringsAsFactors = F, dtoken = token) 
    
    n_ses <- c(rev(dat$Session)[1])
    time <- as.POSIXct(Sys.time())
    time_after_beg <- round(as.numeric(difftime(time, rev(dat$Time)[1], units = "mins")), digits = 0)
    
    catch <- data.frame(n_ses, 
                        time = as.character(time),
                        min_fishing = as.character(time_after_beg),
                        species = input$species,
                        length = input$length,
                        lure = input$lure,
                        colour = input$color_lure,
                        length_lure = input$length_lure)
    
    b <- rbind(caugth,catch)
    
    # Write csv in temporary files of shiny server 
    write_csv(as.data.frame(b), "catch.csv")
    # Upload it to dropbox account 
    drop_upload("catch.csv", path = "App_peche", mode = "overwrite", dtoken = token)
  })
  
  # Create the map with the results of previous session depending on the choice of the user :
  
  observeEvent(input$radio,{
    
    output$map <- renderLeaflet({
      map_data <- map_choice(input$radio)
      leaflet(map_data) %>% addTiles() %>%
        addPopups(lng = ~Long,
                  lat = ~Lat, 
                  with(map_data,
                       sprintf("<b>Session %.0f : %.1f h</b> <br/> %s <br/> %.0f  poissons <br/> hauteur d'eau: %.0f m, %s, %.0f min après l'étal",
                               n_ses,
                               duration,
                               Time,
                               nb,
                               Water_level,
                               Tide_status,
                               Tide_time)),
                  options = popupOptions(maxWidth = 100, minWidth = 50))
    })
    
  })
  
}

Conclusion et améliorations futures

Vous pouvez trouver un exemple de démonstration de cette application (non connectée au compte Dropbox)
ici.
J’utilise cette application depuis un an sans aucun problème ! Les données que j’ai collectées seront présentées dans le prochain article.

Dans les mois à venir, je dois trouver une nouvelle API gratuite pour remplacer l’actuelle. En effet, l’API météo que j’utilise a été rachetée par Apple et les requêtes gratuites seront arrêtées l’année prochaine.

Retour au sommet