PYTHON → ROBOTIQUE · 20

Séance 20 · Partie C · Python avancé

Temps réel
et port série.

Un robot doit lire ses capteurs 50 fois par seconde, exactement, tout en écoutant l’opérateur et en enregistrant un journal. Le PC doit parler à la carte. Cette séance fait le pont entre Python et le matériel.

Durée : 90 min · Objectifs : boucle à cadence fixe, mesure de la gigue, threads et files pour ne pas bloquer, le port série avec pyserial, concevoir un protocole texte simple, un mot sur asyncio.

Ce que vous saurez faire à la fin
  • Écrire une boucle qui tourne à 50 Hz sans dériver.
  • Lire un capteur dans un thread pendant que le programme principal décide.
  • Envoyer des commandes à une carte et lire ses réponses.
  • Définir un protocole robuste entre PC et microcontrôleur.

Le port série n’existe pas dans le navigateur : les exemples série tournent ici sur une carte simulée, et le code pour la vraie carte est donné à côté. Il servira tel quel en séance 26.

01 / Cadence

La boucle naïve dérive

Le principe

Naïf : chaque cycle dure travail + période, donc plus que la période, et l’erreur s’accumule. Correct : on calcule l’instant absolu du prochain cycle (prochain += periode) et on dort jusqu’à lui. Si le travail dépasse, on ne dort pas et on note le retard : c’est un signal qu’il faut alléger le travail. Sur Arduino, c’est le même schéma avec millis() ; sur un système temps réel dur (airbag, pacemaker), dépasser est une faute grave et on prouve que ça n’arrive jamais.

01 / Cadence

Mesurer la gigue

Gigue (jitter)

La gigue est la variation de l’intervalle réel autour de la période. Sur un PC avec un système d’exploitation généraliste, sleep n’est précis qu’à la milliseconde près (le système peut réveiller votre programme en retard : d’autres programmes tournent). Dans le navigateur, c’est encore moins précis. Sur un microcontrôleur nu, la gigue est de quelques microsecondes : c’est la raison d’utiliser un Arduino pour piloter un moteur plutôt qu’un PC. Le PC décide (lentement, intelligemment), le microcontrôleur exécute (vite, précisément).

02 / Concurrence

Le problème : une lecture qui bloque

Bloquant

Un appel bloquant immobilise tout le programme jusqu’à ce qu’il revienne. Pendant ce temps, aucune décision, aucun freinage. Trois solutions : (1) ne pas attendre — demander la mesure et vérifier au tour suivant si elle est arrivée (non bloquant) ; (2) confier la lecture à un thread séparé qui dépose les mesures dans une file ; (3) asyncio, la concurrence coopérative. On voit la 2, la plus courante pour du matériel.

02 / Concurrence

Un thread producteur, une file, un consommateur

Le navigateur n’a pas de threads : ce code s’exécute sur votre PC. La diapositive suivante montre l’équivalent non bloquant exécutable ici.

import threading, queue, time, random

mesures = queue.Queue()             # file thread-safe
stop = threading.Event()

def producteur():
    """Tourne dans son propre thread : lit le capteur lent en boucle."""
    while not stop.is_set():
        time.sleep(random.uniform(0.05, 0.3))
        mesures.put((time.perf_counter(), random.randint(0, 100)))

thread = threading.Thread(target=producteur, daemon=True)
thread.start()

# Boucle principale à 20 Hz : ne bloque jamais
derniere = None
t0 = time.perf_counter(); prochain = t0
for i in range(20):
    while not mesures.empty():                  # récupérer tout ce qui est arrivé
        derniere = mesures.get()
    age = f"{time.perf_counter() - derniere[0]:.2f} s" if derniere else "aucune"
    print(f"cycle {i:2} t={time.perf_counter() - t0:.2f}  dernière mesure : {derniere[1] if derniere else '-':>3}  âge {age}")
    prochain += 0.05
    time.sleep(max(0, prochain - time.perf_counter()))

stop.set()
print("Cycles réguliers, mesures quand elles arrivent.")
Les règles des threads

Un thread est un fil d’exécution parallèle dans le même programme. Deux threads qui touchent la même variable en même temps créent des bugs impossibles à reproduire : on communique uniquement par une queue.Queue (conçue pour cela) et on signale l’arrêt par un Event. daemon=True : le thread meurt avec le programme. En CPython, le GIL (séance 01) empêche deux threads de calculer en même temps, mais ils attendent très bien en parallèle (capteurs, réseau, disque) : c’est exactement notre cas. Pour du calcul lourd parallèle, multiprocessing.

02 / Concurrence

Sans thread : demander, puis vérifier plus tard (non bloquant)

Le modèle « demander / vérifier »

On ne dort jamais en attendant le capteur : on lance la demande, on fait le reste du cycle, on regarde au tour suivant si la réponse est là. C’est exactement ainsi que fonctionne un capteur ultrason sur Arduino (envoyer l’impulsion, mesurer l’écho plus tard) et la plupart des périphériques avec un drapeau « données prêtes ». Ce style, sans thread, est celui des microcontrôleurs : une seule boucle, rien ne bloque. Le thread du PC (diapositive précédente) est un confort ; le non-bloquant est la règle en embarqué.

03 / Série

Le port série : deux fils, des octets, un débit

# Sur votre PC : pip install pyserial
import serial, time

port = serial.Serial("COM3", 115200, timeout=1)   # Windows
# port = serial.Serial("/dev/ttyUSB0", 115200)    # Linux
# port = serial.Serial("/dev/tty.usbmodem14101")  # macOS
time.sleep(2)              # l'Arduino redémarre à l'ouverture du port

port.write(b"LED 1\n")     # des OCTETS, pas une chaîne
ligne = port.readline()    # jusqu'à \n ou timeout
print(ligne.decode().strip())

port.close()
# Trouver le port
from serial.tools import list_ports
for p in list_ports.comports():
    print(p.device, p.description)
  • UART : TX, RX et masse. Chaque octet est envoyé bit par bit, à un débit convenu (baud rate) : 9600, 115200 bits/s.
  • Les deux côtés doivent avoir le même débit, sinon on lit du charabia.
  • L’USB de l’Arduino émule un port série : « COM3 » sous Windows, « /dev/ttyUSB0 » sous Linux.
  • On échange des octets : .encode() pour envoyer du texte, .decode() pour le lire.
  • Un seul programme à la fois peut ouvrir le port (fermer le moniteur série de l’IDE Arduino !).
Combien de données ?

À 115200 bauds, avec 10 bits par octet (start, 8 données, stop) : 11 520 octets/s. Une ligne « D=123.4\n » fait 8 octets : 1400 lignes par seconde maximum. Pour un flux de capteur à 100 Hz, c’est large. Pour une image de caméra, non : c’est pourquoi les caméras passent par USB natif ou WiFi.

03 / Série

Un protocole texte : simple, lisible, déboguable

Pourquoi du texte

Un protocole texte se lit dans le moniteur série, se tape à la main pour tester, et une ligne corrompue est facile à détecter et à ignorer. Le binaire est plus compact et plus rapide (séance 13, le paquet de télémétrie) : on l’utilise quand le débit manque. Règles d’or : une ligne = un message, terminée par \n ; toujours valider avant d’utiliser (le câble USB fait des erreurs) ; ne jamais faire confiance à une valeur sans la borner ; prévoir une commande « STOP » que la carte comprend même si tout le reste est cassé.

03 / Série

Dialogue complet avec une carte (simulée ici)

Ce qui change avec la vraie carte

Une ligne : port = serial.Serial(...) au lieu de CarteSimulee(). Tout le reste est identique — c’est le but de la classe simulée : développer et tester la logique sans matériel, puis brancher. Sur le vrai matériel, ajoutez : time.sleep(2) après l’ouverture, un try/finally qui envoie STOP et ferme le port quoi qu’il arrive (séance 08), et un thread lecteur (diapositive précédente) pour ne pas bloquer sur readline.

03 / Série

Le squelette complet PC ↔ carte

import serial, threading, queue, time, sys

class Liaison:
    def __init__(self, port, debit=115200):
        self.port = serial.Serial(port, debit, timeout=0.1)
        self.mesures = queue.Queue()
        self.stop = threading.Event()
        time.sleep(2)                                    # reset Arduino
        threading.Thread(target=self._lecteur, daemon=True).start()

    def _lecteur(self):
        while not self.stop.is_set():
            ligne = self.port.readline().decode(errors="replace").strip()
            if ligne:
                self.mesures.put(ligne)

    def envoyer(self, cmd, *args):
        self.port.write((cmd + "".join(f" {a}" for a in args) + "\n").encode())

    def derniere_mesure(self):
        m = None
        while not self.mesures.empty():
            m = self.mesures.get()
        return m

    def fermer(self):
        self.envoyer("STOP")
        self.stop.set()
        self.port.close()

if __name__ == "__main__":
    liaison = Liaison(sys.argv[1] if len(sys.argv) > 1 else "COM3")
    try:
        prochain = time.perf_counter()
        while True:                                      # boucle de contrôle à 20 Hz
            m = liaison.derniere_mesure()
            if m: print(m)
            # ... décider, envoyer des commandes ...
            prochain += 0.05
            time.sleep(max(0, prochain - time.perf_counter()))
    except KeyboardInterrupt:
        pass
    finally:
        liaison.fermer()                                 # STOP garanti, même sur Ctrl+C
À garder précieusement

Ce fichier est votre liaison.py pour la séance 26. Il combine tout : cadence fixe, thread lecteur, file, protocole texte, arrêt propre. sys.argv[1] lit le port sur la ligne de commande : python liaison.py COM4. errors="replace" évite qu’un octet corrompu ne plante le décodage UTF-8.

03 / Série

Et asyncio ?

Threads ou asyncio ?

asyncio exécute des coroutines qui se passent la main volontairement à chaque await : un seul thread, pas de course entre threads, et des milliers de tâches légères. Idéal pour beaucoup de sources lentes (réseau, plusieurs cartes). Limite : une coroutine qui calcule longtemps sans await bloque tout, et les bibliothèques doivent être compatibles (pyserial-asyncio existe). Pour un robot avec une carte et un PC, les threads suffisent ; pour un serveur qui gère 50 robots, asyncio.

04 / Défis

Défi ★ — Chien de garde

Consigne

Avec CarteSimulee (recopiez-la), écrire une boucle de contrôle à 10 Hz qui envoie MOTEUR 60, puis surveille l’âge de la dernière mesure : si aucune mesure n’est arrivée depuis plus de 0,3 s, envoyer STOP et afficher « liaison perdue ». Tester avec une sous-classe CartePanne dont _emettre ne produit plus rien après 0,5 s.

Correction
class CartePanne(CarteSimulee):
    def __init__(self):
        super().__init__(); self.t0 = time.perf_counter()
    def _emettre(self):
        if time.perf_counter() - self.t0 < 0.5:
            super()._emettre()
        else:
            self.prochaine = time.perf_counter() + 1   # plus rien ne vient

port = CartePanne()
port.write(b"MOTEUR 60\n")
derniere = time.perf_counter(); prochain = derniere; arrete = False
for _ in range(12):
    while True:
        l = port.readline(timeout=0)
        if not l: break
        if l.startswith(b"D="): derniere = time.perf_counter()
    age = time.perf_counter() - derniere
    if age > 0.3 and not arrete:
        port.write(b"STOP\n"); arrete = True; print("liaison perdue → STOP")
    else:
        print(f"ok, âge {age:.2f} s")
    prochain += 0.1; time.sleep(max(0, prochain - time.perf_counter()))

Un chien de garde (watchdog) est indispensable : si le câble se débranche, le robot ne doit pas continuer à foncer. Les microcontrôleurs ont un watchdog matériel qui redémarre la puce si le programme ne lui « parle » plus : on l’activera en séance 22.

04 / Défis

Défi ★★ — Enregistreur avec horodatage et débit

Consigne

Écrire un enregistreur qui, pendant 1 s, lit les mesures d’une CarteBruitee (sous-classe de CarteSimulee qui corrompt 5 % des lignes) à 20 Hz, valide chaque ligne avec analyser_mesure, écrit les bonnes dans journal.csv avec un horodatage (colonnes : t, D, V, L), et affiche à la fin : nombre de lignes, débit moyen en mesures/s, nombre de lignes malformées ignorées. Sur PC : mettre la lecture dans un thread avec le squelette Liaison.

Correction
import csv
class CarteBruitee(CarteSimulee):
    PERIODE = 0.02
    def _emettre(self):
        avant = len(self.file)
        super()._emettre()
        for i in range(avant, len(self.file)):
            if random.random() < 0.05:
                l = self.file[i].decode()
                self.file[i] = (l[:random.randint(0, len(l) - 1)] + "\x00\n").encode()

port = CarteBruitee(); stats = {"ok": 0, "ko": 0}
port.write(b"MOTEUR 40\n")
t0 = time.perf_counter(); prochain = t0
with open("journal.csv", "w", newline="") as f:
    w = csv.writer(f); w.writerow(["t", "D", "V", "L"])
    while time.perf_counter() - t0 < 1.0:
        while True:
            l = port.readline(timeout=0).decode(errors="replace").strip()
            if not l: break
            m = analyser_mesure(l)
            if m and {"D", "V", "L"} <= m.keys():
                w.writerow([round(time.perf_counter() - t0, 4), m["D"], m["V"], m["L"]]); stats["ok"] += 1
            elif not l.startswith("OK"): stats["ko"] += 1
        prochain += 0.05; time.sleep(max(0, prochain - time.perf_counter()))
print(stats, "→", stats["ok"], "mesures/s")

{"D", "V", "L"} <= m.keys() vérifie que toutes les clés attendues sont présentes (inclusion d’ensembles). La validation stricte rejette ~5 % des lignes : c’est le prix d’un câble réel. Mieux vaut perdre une mesure qu’en utiliser une fausse.

04 / Défis

Défi ★★★ — Esprit prépa : ordonnancement de tâches périodiques

Consigne

Un robot a trois tâches périodiques : contrôle moteur (période 10 ms, durée 3 ms), lecture lidar (période 25 ms, durée 8 ms), planification (période 100 ms, durée 30 ms). Un seul processeur.

1. Calculer le taux d’utilisation U = Σ durée/période. Si U > 1, c’est impossible.
2. Simuler un ordonnanceur à priorité fixe (Rate Monotonic : la période la plus courte a la priorité la plus haute), avec préemption, sur 1 s, et mesurer pour chaque tâche le pire temps de réponse. Une tâche rate-t-elle son échéance ?
3. Augmenter la durée de planification jusqu’à ce qu’une échéance soit ratée. Comparer avec la borne théorique de Liu & Layland : U ≤ n(2^(1/n) − 1) ≈ 0,78 pour 3 tâches.

Correction et ouverture
def simuler(taches, horizon=1000):
    ordre = sorted(taches, key=lambda n: taches[n][0])         # priorité : période courte d'abord
    restant = {n: 0 for n in taches}; libere = {n: 0 for n in taches}; pire = {n: 0 for n in taches}
    for t in range(horizon):                                    # pas de 1 ms
        for n, (periode, duree) in taches.items():
            if t % periode == 0:
                if restant[n] > 0: pire[n] = float("inf")       # l'instance précédente n'a pas fini : échéance ratée
                restant[n] = duree; libere[n] = t
        for n in ordre:                                         # la plus prioritaire qui a du travail s'exécute
            if restant[n] > 0:
                restant[n] -= 1
                if restant[n] == 0:
                    pire[n] = max(pire[n], t + 1 - libere[n])
                break
    return pire

U = sum(d / p for p, d in taches.values())
print(f"U = {U:.2f}", simuler(taches))
for duree_plan in range(30, 80, 5):
    taches["plan"] = (100, duree_plan)
    U = sum(d / p for p, d in taches.values())
    print(f"plan {duree_plan} ms : U = {U:.2f} → {simuler(taches)}")

U = 0,92 et pourtant tout passe (pire réponse de la planification ≈ 75 ms < 100). La borne de Liu & Layland (0,78) est suffisante, pas nécessaire : au-delà, il faut simuler ou faire l’analyse de temps de réponse exacte. À partir de plan = 65 ms (U = 0,97), une échéance est ratée. Cet exercice est le cœur des systèmes embarqués critiques (avionique, automobile) : on y prouve avant le vol que chaque tâche finit à temps. Les systèmes d’exploitation temps réel (FreeRTOS sur ESP32, séance 25) implémentent exactement cet ordonnanceur.

05 / Vérification

Pourquoi time.sleep(periode) après le travail ne donne-t-il pas la bonne cadence ?

Deux questions supplémentaires

1. Comment deux threads doivent-ils communiquer ? Par une queue.Queue, jamais par une variable partagée modifiée des deux côtés.

2. Pourquoi envoyer des octets et pas une chaîne sur le port série ? Le port transporte des octets ; .encode() convertit le texte (UTF-8 ou ASCII) en octets.

Référence

Les mots à retenir

MotDéfinition
Cadence fixeBoucle qui vise des instants absolus, sans dérive.
GigueVariation de l’intervalle réel autour de la période.
BloquantAppel qui immobilise le programme jusqu’à son retour.
ThreadFil d’exécution parallèle ; communique par Queue.
UART / port sérieLiaison octet par octet à débit fixe (bauds).
ProtocoleFormat convenu des messages échangés.
Chien de gardeArrêt de sécurité si le dialogue cesse.

Pour continuer

Fin de la partie C

Vous maîtrisez Python de bout en bout. Il est temps de brancher des fils : la partie D commence par les bases de l’électronique, puis l’Arduino en C/C++.

Matériel à prévoir pour la partie D

À faire chez soi

← Séance 19SommaireSéance 21 : Bases d’électronique →