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.

Software's flowchart, mai 2022
Software's flowchart

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:

Dashboard, mai 2022
Dashboard

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.

Graphique générer par la classe Animated_Graph, mai 2022
Graphique générer par la classe Animated_Graph

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.

Software - Menu principal, mai 2022
Software - Menu principal

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.

Software - Graph page, mai 2022
Software - Graph page

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'environnement
 source env/Scripts/activate
 # installer les librairies
 pip 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.'

Références

Sqlite3
PyQt5
pyqtgraph