22P — Data Analysis Software
Par admin le mardi, mai 10 2022, 12:00 - 2021–2022 - Lien permanent
Ce projet a été réalisé par Rochat, Maxime.
1. Introduction
La conquête spatiale à toujours été un fantasme de l'homme. Comprendre qui nous sommes, la place que nous occupons dans l'univers et les lois qui l'animent étant comme premières raisons de l'étude des astres. À celles-là, s'ajoute de nos jours des motivations économiques. Ce thème central de notre siècle nécessite donc d'attiser la curiosité de la population à propos de l'espace, et c'est notamment la volonté de l'ESA (European Space Agency) qui déploie de nombreuses ressources afin d'éduquer les générations de demain sur ce sujet.
Un des projets sur lesquels ils sont impliqués est le concours cansat qu'ils organisent avec la HESSO. Ce concours à pour but de toucher des jeunes de 14 à 18 ans qui doivent s'organisés en équipe de 4 à 6 personnes. Dans le cadre de ce concours, chaque équipe doit concevoir un "minisatellite" de la taille d'une canette de soda qui est ensuite envoyé à 1000m de hauteurs et qui doit réaliser deux missions. La mission primaire est commune à toutes les équipes, elle consiste à récupérer la température et la pression atmosphérique. La mission secondaire, elle est propre à chaque équipe.
Quelques camarades et moi avons décidé de participer à ce concours. Notre mission secondaire était donc de mesurer la qualité de l'air en mesurant la teneur en CO2 et VOC. Le projet dans son ensemble peut être divisé en 4 parties distincts : le design, le parachute, les systèmes embarqués et le software. Dans ce billet, nous verrons que la partie software. Cette partie se limite ainsi à l'affichage des données au sol du moment où elles sont récupérées par notre récepteur radio, car dans notre projet nous avons aussi tenu à assurer une connexion radio avec notre minisatellite de manière à pouvoir accéder aux données en temps réels.
2. Matériel et méthodes
Concernant le matériel, ce n'est rien de sorcier. Il suffit uniquement d'un ordinateur fonctionnant sur windows
2.1 Matériel
- Un ordinateur avec python 3.9 d'installer ainsi que quelques autres librairies que vous retrouverez dans le fichier "requirements.txt".
2.2 Méthode
Le software est l'interface graphique qui nous permet d'accéder aux données mesurées par la satellite, on peut donc diviser son travail en trois parties. Premièrement, il faut pouvoir récupérer les données reçues par notre récepteur radio au sol, deuxièmement les données doivent être enregistrées dans une base de donnée, et finalement, ces données doivent être disponibles pour l'utilisateur de manière digeste.
2.2.1 Récupération des données
La première étape à réaliser est de pouvoir lire les données reçues par le port USB depuis l'Arduino(micro-controller auquel est connecté notre récepteur). Afin de réaliser cela, il suffit d'utiliser la librairie PySerial qui permet de lire les entrées USB (/!\la librairie ne fonctionne pas sur les ordinateurs Apple).
Afin de pouvoir faire cela, j'ai alors choisi de créer une classe appelée ReadSerial
qui sert à lire les données brutes imprimées sur le port USB et ensuite reformater les données sous forme d'un tuple qui peut ensuite être inséré dans la base de donnée. Les données sont reçues sous forme de "strings" mais organisé en un dictionnaire ce qui me permet d'utiliser la fonction json.loads(dict_str)
pour les récupérer en un objet de type dictionnaire avec lequel je peux travailler. Ci-dessous vous verrez la fonction read_serial
qui est la fonction principale de la classe :
def read_serial(self): #ouvrir le connexion avec le port self.ser.open() try: # lire les données imprimé sur le port dict_str = self.ser.readline().decode() # reformater la string reçu en dict dictionary = json.loads(dict_str) # faire une compréhension de tuple self.new_tuple = tuple(dictionary[key] for key in dictionary.keys()) print("tout va bien") except UnicodeDecodeError: print("read trouble") except json.decoder.JSONDecodeError: print("json decoder error") # fermer la connexion au port self.ser.close() # retourner le tuple avec les données reçues return self.new_tuple
On voit que tout le block qui se charge d'évaluer la chaîne de caractère reçu est dans une structure try / except, cela est fait pour éviter qu'en cas d'erreur de décodage tout le code s'arrête. Grace à ce block si une erreur arrive le tuple précédent va juste être utilisé à la place.
2.2.2 Interaction avec la base de donnée Sqlite3
Maintenant que nous somme capable de lire les données reçu par le port USB, il a fallu créer un moyen d'interagir avec la base de donnée Sqlite3. C'est est une librairie python qui permet d'utiliser une base de donnée SQL sur un fichier local. Pour interagir avec notre base de données, j'ai donc choisi de créer une classe SqliteDb
.
Avant de parler de la classe et des quelques fonctions importantes, il est nécessaire de faire une petite paranthèse sur la bsae de donnée en elle-même et la structure utilisée. La base de donnée est composée d'un tableau avec 7 colonnes : la colonne "t" est un marqueur de temps, c'est la primary key du tableau, ensuite il y a les colonnes temperature, voc, pressure et co2. Ces quatre colonnes sont celles assignées pour les valeurs mesurées. Ensuite il reste encore deux colonnes : sensors_working et data_saved, qui servent à nous données des informations sur l'état du système embarqué.
Parenthèse faite, revenons en à la classe SqliteDb
. Lorsqu'elle s'initialise, elle se connecte à la base de données, ensuite il y a 2 fonctions principales qui permettent d'introduire des valeurs, upload_data
et read_data
. upload_data
est utile pour ajouter une valeur à la base de donnée et se présente comme cela :
def upload_data(self, update_manually = False, tuple = ()): # "reset_cursor()" est une fonction qui ramène le curseur au début de la base de donnée self.reset_cursor() # Il est possible de spécifier avec l'arguement "update_manually" dans quel cas # c'est à nous de spécifier un tuple avec les valeurs à ajouter if not update_manually: # cette fonction permet d'update automatiquement le tuple avec les dernières données # reçu sur le port USB grâce à la classe ReadSerial() self.update_tuple() else: self.tuple = tuple with self.mydb: try: # on effectue une requête SQL pour insérer les données dans la base self.c.execute("INSERT INTO data_collected(t, temperature, voc, pressure, co2, sensors_working, data_saved) VALUES (%s, %s, %s, %s, %s, %s, %s)", self.tuple) self.mydb.commit() except mysql.connector.errors.IntegrityError: pass
Dans cette fonction il y a plusieurs arguments que l'on peut renseigner, update_manually et tuple. Ces arguments permettent de laisser l'option de remplir la base de données manuellement. On peut aussi voir une structure try / except qui permet d'éviter que tout le code se bloque s'il y a une erreur au moment d'insérer les données.
La deuxième fonction importante de cette classe est la fonction read_data
. Cette fonction permet, comme son nom l'indique, de récupérer les données de la base. cette dernière ce structure de telle manière :
def read_data(self): self.reset_cursor() with self.mydb: self.list_time = self.list_temp = self.list_press = self.list_co2 = self.list_voc = self.sensors_on = self.filesave = # on selectionne toutes les données de notre tableau self.c.execute("SELECT * FROM data_collected") data = self.c.fetchall() # on boucle sur la liste data afin de réorganiser chaque données dans des listes spécifique for item in data: self.list_time.append(item[0]) self.list_temp.append(item[1]) self.list_press.append(item[2]) self.list_co2.append(item[3]) self.list_voc.append(item[4]) self.sensors_on.append(item[5]) self.filesave.append(item[6]) # on retourne toutes nos listes spécifique return [self.list_time, self.list_press, self.list_temp, self.list_voc, self.list_co2, self.sensors_on, self.filesave]
Cette fonction est celle qui va être utilisé dans le backend du software pour sélectionner quelles données utiliser.
2.2.3 GUI
La troisième partie du projet, et probablement la plus complexe, est le développement de l'interface graphique. Cette dernière à été réaliser avec la librairie PyQt5 ainsi que de la librairie pyqtgraph pour générer les graphiques. L'objectif est d'avoir un premier menu qui est une vue générale de tous les cinq graphiques et d'un "dashboard" avec quelques infos sur les états des systèmes de bord du minisatellite. Sur ce menu, il y a la possibilité de cliquer sur un des graphiques pour avoir une nouvelle vue avec le graphique en plus grand et quelques infos supplémentaires sur les données affichées. Afin d'accomplir cela, le software est divisé en trois fichiers. Un fichier main.py qui est le fichier principal, un fichier animated_graph.py qui comporte tous les éléments qui gèrent l'affichage des graphiques puis finalement un fichier dashboard.py.
Commençons par le fichier dashboard.py. C'est cette partie qui dirige l'affichage du dashboard sur l'interface. Sur ce dashboard, on peut voir 3 choses. Premièrement, savoir si la connexion radio est établie, deuxièmement, savoir si tous les capteurs fonctionnent et finalement si les données sont sauvées sur une carte SD disposée dans le minisatellite. D'un point de vue pratique, pour connaître ces différentes informations, elles sont déjà transmise par radio pour les 2 dernières, et pour savoir si la connexion radio est établi, le programme assume que c'est le cas tant qu'il reçoit des nouvelles données. Si ce n'est pas le cas la connexion est défini comme non établie. Graphiquement, le dasboard ressemble juste à une liste de trois éléments chacuns précédés d'un rond vert, jaune, ou rouge. de tel manière:
D'un point de vu technique, l'affichage est divisé en 2 éléments. Les cercles de couleurs sont générer et sont disposés dans un gridlayout
et les textes sont ensuite insérés dans le layout de tel manière :
# on ajoute les 3 textes self.grid_layout.addWidget(self.label3, 3, 3, 1, 1) self.grid_layout.addWidget(self.label2, 2, 3, 1, 1) self.grid_layout.addWidget(self.label1, 1, 3, 1, 1) # on ajoute au layout les 2 cercles de couleurs pour chaque textes (3 cercles pour les capteurs) self.grid_layout.addWidget(self.circle_red_radio, 1, 1, 2, 2) self.grid_layout.addWidget(self.circle_green_radio, 1, 1, 2, 2) self.grid_layout.addWidget(self.circle_green_sensors, 2, 1, 2, 2) self.grid_layout.addWidget(self.circle_yellow_sensors, 2, 1, 2, 2) self.grid_layout.addWidget(self.circle_red_sensors, 2, 1, 2, 2) self.grid_layout.addWidget(self.circle_green_filesave, 3, 1, 2, 2) self.grid_layout.addWidget(self.circle_red_filesave, 3, 1, 2, 2)
Le deuxième composant de l'intérfaface est le graphique. Ce composant est généré par la classe Animated_Graph
. Le programme fonctionne en 3 parties. Premièrement, le programme, à l'aide de la classe SqliteDb
se connecte à la base de données et récupère les données. Dans un second temps elle les traitent afin de ne pas afficher les données qui semblent peux cohérente. Pour définir si une valeur n'est pas valide on utilise comme base la moyenne des dernières 50 valeurs reçues. Si une donnée à une différence avec la moyenne plus grande que 1/3 de cette moyenne elle n'est pas prise en compte. La sensibilité peut variée, mais dans notre cas fonctionnait très bien, sachant que nous ne devions pas nous attendre à de grandes variations.
Ensuite vient l'affichage. Les point sont simplement disposé dans 3 listes, une qui correspond à l'axe des x et l'autre des y(après avoir été filtré) et sont affiché à l'aide de la librairie pyqtgraph, ce graph et mis à jour chaque seconde.
Dans cette classse, il y a aussi plusieurs outils qui nous permettent d'analyser les données reçues. Tout d'abord il y a une droite qui nous indique la pente formé par les 2 derniers points reçu qui nous donne une idée de la tendance que le graphique prend. Ensuite il y a aussi un texte qui est généré et qui nous indique différents informations concernant le graphe tel que le maximum, le minimum, la pente, la moyenne et la différence depuis le début du graphique.
La plus grosse partie de l'intérface se trouve dans le fichier main.py. En effet, c'est la que les 2 éléments précédents sont réunis et que toutes les connexions de boutons sont faites ainsi que la création des 4 graphiques spécifiques à chaque donnée mesurée. Chacun des graphiques et le dashboard sont disposés sur l'interface à l'aide d'un gridlayout, ensuite est rajouté sur la droite une barre de menu qui permet d'afficher la vue de chacun de graphiques en plus précis avec les informations supplémentaires le concernant.
Ensuite si l'utilisateur clique sur l'un des graphiques l'affichage change laissant place au widget du grahique et de son texte et avec sur la droite 1 bouton pour retourner au menu principal et l'autre permet de spécifier si l'on veut avoir un focus fait sur les derniers points du graphiques ou si l'on veut être libre de se déplacer manuellement.
Techniquement, ce n'est pas très compliqué, le programme se resume principalement en la descrition, c'est à dire qu'on passe notre temps à appeler des fontion tel que graph.show()
ou graph.hide()
en fonction de ce que l'on veut afficher. par exemple voilà comment l'on place tous le graphiques sur le menu principal :
# Ici on crée une instance de la classe Animated_graph pour chaque graphique que l'on veut afficher self.graph1 = Animated_graph("Pressure", "Time (s)", "Pressure (hPa)", index_of_data=1) self.graph2 = Animated_graph("Temperature", "Time (s)", "Temperature(°C)", index_of_data=2) self.graph4 = Animated_graph("CO2", "Time (s)", "CO2", index_of_data=3) self.graph5 = Animated_graph("VOC", "Time (s)", "VOC", index_of_data=4) # on les ajoute au layout avec le titre self.layout_main.addWidget(self.title_label, 0, 1, 1, -2) self.layout_main.addWidget(self.label, 5, 0, 3, 7) self.layout_main.addWidget(self.graph5, 5, 12, 3, 3) self.layout_main.addWidget(self.graph2, 2, 11, 3, 4) self.layout_main.addWidget(self.graph1, 2, 0, 3, 11) self.layout_main.addWidget(self.graph4, 5, 7, 3, 5)
et voilà à quoi ressemble une fonction qui permet d'afficher un graphique en particulier :
def show_graph_1(self): # On désactive les boutons invisbles qui mènent aux vues spécifiques self.disable_invisible_button() # On montre les éléments nécessaires et cache les autres self.title_label.hide() self.graph1.show() self.graph2.hide() self.label.hide() self.graph4.hide() self.graph5.hide() # On appel la fonction qui met tout en forme self.show_graph_page(self.graph1)
3. Quickstart
Avant la suite du billet je me permet de faire un petit guide pour faire fonctionner le programme. Le projet s'organise de telle manière :
p/
├─ PyQT/
│ ├─ animated_graph.py
│ ├─ sources/
│ │ ├─ co2_icon.png
│ │ ├─ press_icon.png
│ │ ├─ cansat_pp.ico
│ │ ├─ temp_icon.png
│ │ ├─ voc_icon.png
│ ├─ __pycache__/
│ ├─ dashboard.py
│ ├─ main.py
│ ├─ sqlite_database.py
data.db
requirements.txt
3.1 Setup de l'enviornnement
Pour que le programme fonctionne sans accro voilà un guide pour setup un environnement virtuel avec toutes les librairies nécessaires. D'abord il faut créer l'environnement, cela peut être fait avec la commande suivante
python3.9 -m venv env
Cette fonction va créer un environnement virtuel avec python 3.9 (/!\ il es important d'avoir python 3.9, certaines autres versions ne fonctionnent pas avec les librairies que j'utilise)
Ensuite il faut installer toutes les librairies. Pour cela, il faut, lorsqu'on est situé au niveau du dossier "p/" invoquer la commande
# activer l'environnementsource env/Scripts/activate
# installer les librairiespip install -r requirements.txt
Maintenant que tout est bien installé il ne suffit plus que de lancé le fichier "main.py". Normalement l'intérface devrait se dévoiler à vous dans les quelques secondes qui suivent
4. Résultats
Le résultat obtenu est à la hauteur de mes attentes. En effet, malgré les difficultés rencontrées lors du développement de cette interface, je suis satisfait de ce que j'ai pu accomplir. Un des points que je tenais à réussir était de ne pas simplement obtenir une interface utile mais qu'elle soit graphiquement belle et érgonomique, ce qui est selon moi réussi. De plus son objectif primaire est de même réussi, on arrive à visualiser toutes les données nécessaires.
5. Discussion
Bien que satisfait du résultat il faut rester critique. Certains éléments, bien que fonctionnels sont loins d'être parfait et auraitent pu être optimisés sur plusieurs plans. Principalement lié à la génération de l'intérface dû au fait que c'est un tout autre paradigme de programation et que je ne connaissais pas beaucoup la librairie PyQt5 quand je me suis lancé dedans.
5.1 Les graphiques
Les graphiques sont les composants centraux de l'interface. Cependant c'est aussi à cause d'eux que l'on peut rencontrer certains problème. En effet, lorsqu'il faut update les 4 graphiques en live avec plus de ~250 datapoints j'ai fait face à des ralentissements. Cela est dû au fait que les graphiques sont rendus chaque sconde et que lorsqu'un nouveau point s'ajoute ce n'est pas seulement un nouveau point qui est dessiner mais c'est tout les point qui sont regénérer. Ceci mène à de lourd ralentissement quand les graphiques sont chargées. Malheureuement la librairie ne propose pas de solution pour éviter tout ce travail supplémentaire inutile. Pour régler ce problème la plupart des blogs conseil de développer un algorithme qui limite les point afficher et qui ne rend que les points importants afin de limiter la charge. Cependant c'est une fonctionnalitée qui me semblait complexe à développé et au moment ou je réfléchissais à implémenter cette fonction d'autre domaine pour le concours avait besoin en priorité d'être développé.
Une autre possible solution qui aurait pu être exploré était le "multithreading". La librairie PyQt5 fonctionne avec une système de "thread", qui consiste en différentes files qui tournent en arrière plan et qui s'assurent du bonne affichage de l'interface, ou encore d'une file qui s'occupe d'écouter les différents signaux que l'utilisateur pourrait déclencher comme un bouton ou un champ qu'il aurait rempli. Maintenant, certains types d'actions ont leurs propre thread, mais les éléments que le développeur choisi d'afficher ne font pas forcément partie de ces éléments. Ceci veut dire que tous les graphiques sont générer dans le même thread, une solution intéressante aurait été de séparer les thread et en donnée un indépendant pour chaque graphique.
Malheureusement comme pour la première proposition, d'autres systèmes nécessitaient une attention plus urgente et de plus le "multithreading" paraissait nécessité des connaissance assez pointu en programmation et dans l'utilisation de la librairie.
5.2 L'affichage des différentes vues
De part ma connaissance rédutie de la librairie utilisée pour construire l'intérface au début du projet et qui reste maintenant encore limité, j'ai commis des erreurs de base ayant des répercussions à différents niveaux et probablement sur l'optimisation de l'UI. La plus grosse erreur se trouve dans la manière d'appeler les différents affichages. En effet chaque fois que la vue d'un graphique en particulier est nécessité ou que je retourne sur le menu principal c'est manuellement que chacun des graphiques sont montrés ou cachés. Ceci n'est pas très optimisé car cela nécessite la répétition de beaucoup de ligne. De plus cela signifie aussi que si l'on voulait aujouter des éléments il serait nécessaire de spécifier à maintes reprises ce nouvel élément. Pour régler ce problème une option à explorer serait d'utiliser le widget QFrame
qui permettrait a priori de mettre dessus tous les élément que l'on veut afficher sur le menu ou une vue spécifique et qui ensuite peut facielement être appelé en une fonction.
6. Conclusion
Pour conclure, ce projet était très intéressant à réaliser car il a permis de m'intéresser au design d'intérefaces graphiques qui est évidemment un élément important lorsque l'on veut intéragir avec un utilisateur. De plus, ce projet s'inscrit dans le cadre d'une compétition ce qui ajoute une pression supplémentaire mais aussi un enjeu. J'ai eu l'occasion aussi de toucher (même si c'était pour des actions très simples) aux bases de données apprenant les bases du SQL qui est un language très utile dans tous les domaines de l'informatiques lorsqu'il s'agit d'intéragir avec des bases de données. De plus c'est, d'un point de vue personnel, une réussite car j'ai réussi à remplir tous les objectifs que je me suis fixés.'