La politique de l'équipe étant de créer notre drone de A à Z, il était tout naturel de ne pas acheter une carte de vol déjà assemblée. Cela nous a permis de faire d'énormes économies tout en restant compétitifs. Si vous aussi vous vous intéressez à la création de drone, voici de A à Z le fonctionnement de la HePilot 1.0.
La carte HePilot est composée d'un micro-controlleur Arduino uno, d'une centrale inertielle (IMU) 9 degrés de liberté MPU-9150, d'un shield xbee avec le module qui va bien (xbee pro) et de quelques résistances.
Elle permet de :
L'IMU, et les résistances sont directement soudées sur la partie “prototype” du shield XBee, avec quelques connecteurs et des ponts en fils, il est simple de reproduire le schéma ci-dessus quand l'on a de l'étain de bonne qualité.
Dans le schéma de fonctionnement, deux parties nécessitent des explications. L'IMU communique avec l'Arduino à l'aide du protocole I2C et a donc besoin de deux résistances appelées “pull up”, Les résistances utilisées sont des 2.2 kΩ.
Le deuxième point important de la carte est le circuit de lecture de tension pour la batterie. En effet il était capital de surveiller la charge de notre batterie pour pouvoir poser le drone avant qu'il ne tombe tout seul. Pour cela nous utilisons une entrée analogique de l'Arduino comme un voltmètre car la tension délivrée par la batterie dépend directement de la charge. Petit problème : notre batterie est une 3S ce qui signifie qu'elle délivre une tension comprise entre 9 et 12 volts, tandis que l'entrée analogique lit des valeurs comprises entre 0 et 5 volts. Nous avons donc monté un diviseur de tension qui nous permet de diviser la tension de notre batterie par 3, il suffit ensuite de re-multiplier notre valeur dans le code, mais ce travail est effectué par la GCS.
Pour configurer les 2 XBees, nous avons utilisé notre xbee explorer et le logiciel gratuit Coolterm. La première étape à été de relever les adresses des deux modules écrites au dos de ceux-ci. Ensuite nous les avons configurer de tel sorte que :
Pour cela nous avons utilisé les commandes suivantes :
+++ // Rentre en mode configuration ATID01 // Réseau 01 ATDH13A200 // Partie haute de l'adresse de destination ATDL408CBB9F // Partie basse de l'adresse de destination ATBD3 // Baud rate de 9600 ATWR // On oublie pas de sauvegarder les changements
Avant de se lancer dans le code, nous allons essayer de comprendre le fonctionnement du code de guidage / stabilisation.
Le pilote envoie 4 commandes au drone :
Par convention, une valeur positive voudra dire : monter, tourner dans le sens horaire, avancer, aller à droite
Nous devons donc convertir ces 4 commandes (P, L, T, R) en informations de vitesse utilisables par les moteurs:
La carte ne devra pas seulement s’occuper du vol, mais aussi de toutes les fonctionnalités additionnelles. Certaines tâches sont moins importantes que d’autres et/ou ne nécessitent pas d’être exécutées aussi souvent.
Vous en avez peut-être déjà entendu parler au cours de vos recherches, il peuvent être qualifiés de stabilisateurs de vol. Je vais ici essayer d’expliquer le principe et le fonctionnement des contrôleurs PID.
Un contrôleur PID (Proportionnel, Intégrale, Dérivée) est un système fermé dont le but est de s’approcher au plus près d’un résultat voulu, en se basant sur des données de capteurs. Pour le drone, ils sont en charge d’assurer la stabilité, il en faudra donc un par axe de rotation (lacet, roulis, tangage).
Le principe est en fait assez simple : 3 algorithmes sont combinés en fonction de coefficients kP, kI et kD qui déterminent le style de vol de l’appareil.
Nos 3 algos ont besoins de 2 entrées : la valeur voulue, dite de référence (ref), c’est celle que le pilote demande et la valeur réelle (input), détectée par l’IMU (Inertial Measurement Unit).
Des 3 algos, le plus simple est l’algo proportionnel, ce qui tombe bien car c’est aussi le plus important !
P = kP * (ref - input)
L’algo d’intégration a pour but de garder une trace des petites erreurs de positionnement commises tout au long du vol, sa valeur dépend donc de ses valeurs précédentes. Au début du vol, I = 0.
I += kI * (ref - input) * dt
dt est l'intervalle de temps qui s’est écoulé entre 2 calculs de I, ce paramètre intervient aussi lors du calcul suivant.
L’algo de dérivation, contrairement au précédent essaie d’anticiper les erreurs futures :
D = - kD * (input - prevInput) / dt
Où prevInput est la valeur de input qui a servi à calculer la valeur précédente.
Pour finir, on fait la somme de ces 3 algorithmes :
return P + I + D;
Et notre valeur est prête à être utilisée dans nos équations de moteurs (voir plus haut).
Proportionnel : le plus important, plus il est grand, plus le drone réagira rapidement. Par contre s’il est trop élevé, il sera trop sensible aux petites oscillations.
Intégrale : augmente la précision. Il est très utile pour contrer le vent et la turbulence des moteurs.
Dérivée : plus il est élevé, plus le drone sera sensible au moindre changement. Attention à ne pas trop en abuser.
Quelques exemples de configurations trouvées sur internet :
Vol acrobatique : kP un peu plus élevé kI un peu moins élevé kD plus élevé
Vol stable : kP un peu moins élevé kI un peu plus élevé kD moins élevé
Nous devrions profiter de l’avantage que nous avons, d’avoir opté pour une commande PC et lui ajouter la possibilité de changer les paramètres kP, kI et kD de chaque axe via la liaison sans fil. Cela permettra de faire des tests sans avoir à reprogrammer le drone et de changer de configuration (acrobatique / stable) simplement pendant l’épreuve.
Les quadricoptères étant symétriques, ont peut utiliser les mêmes coefficients kP, kI et kD pour l’axe de tangage et de roulis. L’axe de lacet, n’est pas primordial pour garder l’appareil en l’air, donc, est plus simple à régler. Aussi, on devrait utiliser 2 contrôleurs PID pour les axes de tangage et de roulis, un pour la vitesse (accéléromètre) et un pour la rotation (gyroscope), on les combine ensuite de la sorte :
pid_vit(pid_rot(ref_roll, gyroRoll), accelDroite);
Nous avons opté pour un protocole de communication simple et efficace. Afin de réduire considérablement la taille des paquets échangés, tout en gardant une précision acceptable, nous avons décider d'encoder toutes nos valeurs en binaire. Nos valeurs étant des entiers flottant, il nous faut 4 octets pour en encoder une.
Nous divisons les paquets de notre protocole en deux catégories, selon leur sens.
GCS → Drone (Paquets de commande)
Drone → GCS (Paquets d'information)
Nous utilisons une astuce de code C / Arduino pour recomposer notre entier flottant à partir des 4 octets :
union btf { float f; byte b[4]; } byteToFloat; // Lecture des octets byteToFloat.b[3] = Serial.read(); byteToFloat.b[2] = Serial.read(); byteToFloat.b[1] = Serial.read(); byteToFloat.b[0] = Serial.read(); // Notre valeur flottante : float value = byteToFloat.f;
/* * HePilot * Team Helium - Game of Drones * Author: Arthur Hennequin */ #include "Wire.h" #include "I2Cdev.h" #include "MPU6050_9Axis_MotionApps41.h" #include "PID.h" #include <Servo.h> #define PIN_M1 6 #define PIN_M2 9 #define PIN_M3 10 #define PIN_M4 11 #define PIN_VCC 2 #define DEBUG // Controlleurs PID PID pid_yaw(1, 0, 0); // Lacet PID pid_pitch(1, 0, 0); // Tangage PID pid_roll(1, 0, 0); // Roulis // Moteurs Servo m1; Servo m2; Servo m3; Servo m4; // IMU MPU6050 imu; bool dmpReady = false; // set true if DMP init was successful uint8_t mpuIntStatus; // holds actual interrupt status byte from MPU uint8_t devStatus; // return status after each device operation (0 = success, !0 = error) uint16_t packetSize; // expected DMP packet size (default is 42 bytes) uint16_t fifoCount; // count of all bytes currently in FIFO uint8_t fifoBuffer[64]; // FIFO storage buffer float ypr[3]; Quaternion q; // [w, x, y, z] quaternion container VectorFloat gravity; float prevYaw = 0; float prevPitch = 0; float prevRoll = 0; float yaw = 0; float pitch = 0; float roll = 0; float vX = 0.; float vY = 0.; float vZ = 0.; float vcc = 0.; // Commandes float ref_thrust = 0; // Puissance float ref_yaw = 0; // Lacet float ref_pitch = 0; // Tangage float ref_roll = 0; // Roulis char type; union btf { byte b[4]; float f; } byteToFloat; // Gestion du temps long keepAliveTime; long currentTime; long lastTime; long dT; int frameCounter; void setup() { Wire.begin(); Serial.begin(9600); // Ouvre la connexion xBee ou série // Init motors pinMode(PIN_M1, OUTPUT); pinMode(PIN_M2, OUTPUT); pinMode(PIN_M3, OUTPUT); pinMode(PIN_M4, OUTPUT); m1.attach(PIN_M1); m2.attach(PIN_M2); m3.attach(PIN_M3); m4.attach(PIN_M4); // Calibration m1.write(170); // Max m2.write(170); m3.write(170); m4.write(170); delay(5000); // Brancher ESCs m1.write(10); // Min m2.write(10); m3.write(10); m4.write(10); delay(5000); // Init IMU #ifdef DEBUG Serial.println("InitIMU"); #endif imu.initialize(); devStatus = imu.dmpInitialize(); if (devStatus == 0) { #ifdef DEBUG Serial.println("IMU ok"); #endif imu.setDMPEnabled(true); mpuIntStatus = imu.getIntStatus(); dmpReady = true; packetSize = imu.dmpGetFIFOPacketSize(); } imu.resetFIFO(); lastTime = micros(); keepAliveTime = millis(); } float lerp(float a, float b, float r) { return a*r + b*(1-r); } void readIMUData() { if (!dmpReady) return; // wait for MPU interrupt or extra packet(s) available while (fifoCount < packetSize) { fifoCount = imu.getFIFOCount(); } mpuIntStatus = imu.getIntStatus(); // get current FIFO count fifoCount = imu.getFIFOCount(); // check for overflow (this should never happen unless our code is too inefficient) if ((mpuIntStatus & 0x10) || fifoCount == 1024) { // reset so we can continue cleanly imu.resetFIFO(); #ifdef DEBUG Serial.println("OVF"); #endif // otherwise, check for DMP data ready interrupt (this should happen frequently) } else if (mpuIntStatus & 0x02) { // wait for correct available data length, should be a VERY short wait while (fifoCount < packetSize) fifoCount = imu.getFIFOCount(); // read a packet from FIFO imu.getFIFOBytes(fifoBuffer, packetSize); // track FIFO count here in case there is > 1 packet available // (this lets us immediately read more without waiting for an interrupt) fifoCount -= packetSize; // display Euler angles in degrees imu.dmpGetQuaternion(&q, fifoBuffer); imu.dmpGetGravity(&gravity, &q); imu.dmpGetYawPitchRoll(ypr, &q, &gravity); #ifdef DEBUG Serial.println("ypr"); #endif //#define kFilteringFactor 0.5 //yaw = lerp(ypr[0]*180/M_PI, prevYaw, kFilteringFactor); //pitch = lerp(ypr[1]*180/M_PI, prevPitch, kFilteringFactor); //roll = lerp(-ypr[2]*180/M_PI, prevRoll, kFilteringFactor); yaw = ypr[0]*180/M_PI; pitch = ypr[1]*180/M_PI; roll = -ypr[2]*180/M_PI; //prevYaw = yaw; //prevPitch = pitch; //prevRoll = roll; } } float correctAngle(float angle) { if (angle>360) { return angle - 360; } else if (angle<0) { return angle + 360; } return angle; } float clamp(float value, float a, float b) { return min(max(value, a), b); } void updateMotors(float dt) { float Y = pid_yaw.compute(ref_yaw, yaw, dt); float P = pid_pitch.compute(ref_pitch, pitch, dt); float R = pid_roll.compute(ref_roll, roll, dt); if ((millis() - keepAliveTime) <= 2000 && ref_thrust > 0.f) { m1.write(map(clamp(ref_thrust - Y + P, 0, 100), 0, 100, 10, 170)); m2.write(map(clamp(ref_thrust + Y + R, 0, 100), 0, 100, 10, 170)); m3.write(map(clamp(ref_thrust - Y - P, 0, 100), 0, 100, 10, 170)); m4.write(map(clamp(ref_thrust + Y - R, 0, 100), 0, 100, 10, 170)); } else { m1.write(10); m2.write(10); m3.write(10); m4.write(10); } } float readSerialFloat() { byteToFloat.b[3] = Serial.read(); byteToFloat.b[2] = Serial.read(); byteToFloat.b[1] = Serial.read(); byteToFloat.b[0] = Serial.read(); return byteToFloat.f; } void readCommands() { if (Serial.available()>=5) { type = Serial.read(); if (type=='T') { ref_thrust = readSerialFloat(); } else if (type=='Y') { ref_yaw = readSerialFloat(); } else if (type=='P') { ref_pitch = readSerialFloat(); } else if (type=='R') { ref_roll = readSerialFloat(); } else if (type=='K') { float kP = readSerialFloat(); float kI = readSerialFloat(); float kD = readSerialFloat(); pid_yaw.setCoeffs(kP, kI, kD); pid_pitch.setCoeffs(kP, kI, kD); pid_roll.setCoeffs(kP, kI, kD); } else if (type=='Z') { keepAliveTime = millis(); } } } void monitorBattery() { vcc = analogRead(PIN_VCC) / 1023. * 5.; } void writeSerialFloat(float f) { byteToFloat.f = f; Serial.write(byteToFloat.b[3]); Serial.write(byteToFloat.b[2]); Serial.write(byteToFloat.b[1]); Serial.write(byteToFloat.b[0]); } void sendIMUData() { Serial.write(0xBE); writeSerialFloat(yaw); writeSerialFloat(pitch); writeSerialFloat(roll); writeSerialFloat(vcc); } void loop() { currentTime = micros(); dT = currentTime - lastTime; if (dT >= 10000) { // 100 Hz = 10ms frameCounter++; // 100 Hz - Grande priorité readIMUData(); updateMotors(dT * 0.000001); // dT en secondes // 50 Hz - Moyenne priorité if (frameCounter%2) { readCommands(); } // 25 Hz - Basse priorité if (frameCounter%4) { monitorBattery(); sendIMUData(); } lastTime = currentTime; if (frameCounter==100) frameCounter = 0; } }
Classe PID.h :
#ifndef PID_h #define PID_h #include "Arduino.h" class PID { public: PID(float p, float i, float d) { kP = p; kI = i; kD = d; iTerm = 0; } void reset() { iTerm = 0; } float compute(float ref, float input, float dt) { float error = ref - input; pTerm = error; iTerm += error * dt; dTerm = (input - prevInput) / dt; prevInput = input; return kP*pTerm + kI*iTerm - kD*dTerm; } void setCoeffs(float p, float i, float d) { kP = p; kI = i; kD = d; iTerm = 0; } private: float prevInput; float pTerm, iTerm, dTerm; float kP, kI, kD; }; #endif
A rédiger