PnX-SI / GeoNature

Application de saisie et de synthèse des observations faune et flore
GNU General Public License v3.0
104 stars 102 forks source link

[IMPORTV3] Graphique générique pour le rapport d'import #2996

Open jacquesfize opened 6 months ago

jacquesfize commented 6 months ago

Contexte

Dans le cadre de l'évolution du module d'import, il est question de permettre d'intégrer différentes destinations : Synthèse, OccHab, etc...

Dans le rapport d'un import pour la synthèse, un graphique permet de visualiser la distribution des taxons selon différentes catégories (rang, genre, groupe_inpn, etc..). Pour le moment, aucun graphique n'existe dans le rapport de l'import OccHab. Dans la perspective d'ajouter de nouvelles destinations dans le module d'import, la généricité de ce graphique est une nécessité !

Dans cette issue, je propose de discuter de ce sujet. Pour débuter les discussions, je propose de s'appuyer sur une première proposition.

N.B.: Le sujet de la généricité des graphiques dépassent le cadre seul du module d'import ! Les solutions discutées ici sont envisagées pour l'import et GeoNature !

Solution proposée : génération du graphique côté backend dans une route partagée par toutes les destinations

Dans la nouvelle version de l'import de GeoNature, chaque destination (module GeoNature) doit définir une nouvelle classe dans un fichier module.py qui déclarent différentes méthodes et variables propres à la destination :

class OcchabModule(TModules):
    __mapper_args__ = {"polymorphic_identity": "occhab"}

    def generate_input_url_for_dataset(self, dataset):
        return f"/import/occhab/process/upload?datasetId={dataset.id_dataset}"

    generate_input_url_for_dataset.label = "Importer des habitats"

    _imports_ = {
        "preprocess_transient_data": preprocess_transient_data,
        "check_transient_data": check_transient_data,
        "import_data_to_destination": import_data_to_occhab,
        "remove_data_from_destination": remove_data_from_occhab,
        "statistics_labels": [
            {"key": "station_count", "value": "Nombre de stations importées"},
            {"key": "habitat_count", "value": "Nombre d’habitats importés"},
        ],
    }

En s'appuyant sur cette classe, on pourrait imaginer un nouvel attribut dans __imports__ : report_plot qui retourne les données nécessaires au rendu du graphique (en JSON).

Ensuite, il suffit au frontend de faire appel à une route commune qui récupère et retourne le résultat de la fonction stockée dans report_plot. Enfin, à l'aide de la partie JS de la librairie choisie, reconstruire le graphique dans l'interface à l'aide des données JSON.

En résumé :

Génération du graphique côté backend

Librairies testées

Dans le domaine de la datascience, il existe plusieurs librairies Python permettant de faire des graphiques interactifs s'appuyant sur du HTML/CSS/JS. Ici, je mentionnerais : plotly ,bokeh.

Chacune de ces librairies permet de générer un graphique et de pouvoir exporter ce dernier en JSON. Cependant, plotly (à ma connaissance) n'exporte que le graphique et pas les callbacks (permettant d'interagir sur le contenu du graphique). Contrairement à bokeh !

NB Il existe certainement d'autres librairies permettant d'exporter les callbacks et les graphiques ! (à fouiller)

POC

Ci-dessous, vous trouvez un poc (proof-of-concept) qui reproduit le processus (simplifié) de la procédure présenté ci-dessus. Capture vidéo du 22-04-2024 16_26_59

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>TEST</title>
    {% for js_cdn in js_cdns %}
        <script src="{{js_cdn}}" crossorigin="anonymous"></script>
    {% endfor %}

</head>
<body>
    Ho hi there! 
    <div id="chart">

    </div>
</body>
<script>
    fetch('/plot')
    .then(function(response) { return response.json(); })
    .then(function(item) { Bokeh.embed.embed_item(item,"chart"); })
</script>
</html>

app.py

import json
import time

import numpy as np
from bokeh.embed import json_item
from bokeh.layouts import column, row
from bokeh.models import CustomJS, Select, ColumnDataSource
from bokeh.plotting import figure
from bokeh.resources import CDN

from flask import Flask, jsonify, render_template
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.orm import DeclarativeBase
import sqlalchemy as sa
import pandas as pd

class Base(DeclarativeBase):
    """Base class for SQLAlchemy models"""

app = Flask(__name__)

db = SQLAlchemy(model_class=Base)

app.config["SQLALCHEMY_DATABASE_URI"] = (
    "postgresql://geonatadmin:geonatadmin@localhost:5432/geonature_importv3"
)
# initialize the app with the extension
db.init_app(app)

def data_synthese() -> list:

    data = (
        db.session.execute(sa.text("SELECT * FROM gn_synthese.synthese"))
        .mappings()
        .all()
    )
    data = [dict(row) for row in data]
    return data

def plot_per_observer(df: pd.DataFrame) -> figure:

    count_df = (
        df["observers count_max count_min".split()]
        .groupby("observers", as_index=False)
        .sum()["observers count_max count_min".split()]
        .sort_values("count_max", ascending=False)
    )

    source = ColumnDataSource(count_df)
    p = figure(
        width=800,
        height=300,
        y_range=count_df.observers.unique(),
        title="Number of observed species per observers",
    )

    p.hbar(y="observers", right="count_max", height=0.5, source=source)
    return p

def plot_per_species(df: pd.DataFrame) -> figure:

    count_df = (
        df["nom_cite count_max count_min".split()]
        .groupby("nom_cite", as_index=False)
        .sum()["nom_cite count_max count_min".split()]
        .sort_values("count_max", ascending=False)
        .head(10)
    )

    source = ColumnDataSource(count_df)

    nom_cites = count_df.nom_cite.values

    p2 = figure(
        width=800,
        height=300,
        y_range=nom_cites,
        title="Occurrence per taxon in the dataset ",
        visible=False,
    )

    p2.hbar(y="nom_cite", right="count_max", height=0.5, source=source)
    return p2

def plot_synthese() -> figure:

    df = pd.DataFrame.from_dict(data=data_synthese())

    col = column(plot_per_observer(df), plot_per_species(df))

    s = Select(value="per_observers", options=["per_observers", "per_species"])
    s.js_on_change(
        "value",
        CustomJS(
            args=dict(s=s, col=col),
            code="""
        for (const plot of col.children) {
            plot.visible = false
        }
        if (s.value == "per_observers") {
            col.children[0].visible = true
        }
        else if (s.value == "per_species") {
            col.children[1].visible = true
        }
    """,
        ),
    )
    fig = row(s, col)
    return fig

@app.route("/plot")
def generate_plot() -> str:

    return json.dumps(json_item(plot_synthese()))

@app.route("/")
def index() -> str:

    return render_template("index.html", js_cdns=CDN.js_files)

if __name__ == "__main__":
    app.run(host="127.0.0.1", port=3520, debug=True)

Remarques sur la solution proposée

camillemonchicourt commented 6 months ago

Voir aussi https://github.com/PnX-SI/GeoNature/issues/2719 Et https://github.com/PnX-SI/GeoNature/issues/2862

TheoLechemia commented 6 months ago

+1 pour la simplicité et la généricité de la génération de graphiques. Dans le contexte du module d'import ou frontend est très générique et le backend spécifique à chaque module, ça me parait bien approprié. Petit warning sur le petit changement de paradigme que ça implique : on est plus sur un backend complétement agnostique du frontend. Ici le backend génère du javascript inteprété côté client. On ne délègue plus tout à angular. ça necessite aussi de bien synchroniser la lib Bokeh backend de celle chargée dans le frontend

camillemonchicourt commented 6 months ago

La solution semble intéressante mais il faudrait évaluer si cela n'ajoute pas trop de dépendances qui pourraient compliquer et alourdir la maintenance globale ?