PYTHON → ROBOTIQUE · 28

Séance 28 · Partie E · Robotique

Capteurs et
machine à états.

Un robot qui évite les obstacles avec trois if finit coincé dans un coin, à osciller. Un robot qui a des états — explorer, contourner, reculer, chercher — s’en sort. Aujourd’hui : donner un comportement au robot, et le rendre robuste.

Durée : 2 séances · Objectifs : radar ultrason sur servo, capteurs de ligne infrarouges, filtrage embarqué, la machine à états finis (FSM), comportements en couches, simulateur de comportement, tests.

Ce que vous saurez faire à la fin
  • Percevoir l’environnement avec plusieurs capteurs et les combiner.
  • Concevoir un comportement comme un graphe d’états et le coder proprement.
  • Tester le comportement en simulation avant le vrai robot.
  • Faire cohabiter sécurité, comportement et télémétrie sans bloquer.

Matériel : le robot de la séance 27, capteur ultrason sur servo, 2 ou 3 capteurs infrarouges TCRT5000, éventuellement 2 capteurs de contact (bumpers).

01 / Percevoir

Le radar : ultrason sur servo, non bloquant

# MicroPython — radar.py : balaye et garde la dernière distance par secteur
from machine import Pin, PWM, time_pulse_us
import time

class Radar:
    ANGLES = (30, 60, 90, 120, 150)             # 5 secteurs, 90 = devant
    def __init__(self, servo_pin, trig, echo):
        self.servo = PWM(Pin(servo_pin)); self.servo.freq(50)
        self.trig, self.echo = Pin(trig, Pin.OUT), Pin(echo, Pin.IN)
        self.distances = {a: None for a in self.ANGLES}
        self.i = 0; self.sens = 1; self.t_servo = 0; self.etape = "tourner"
    def _angle(self, a): self.servo.duty_u16(int((1 + a / 180) / 20 * 65535))
    def _mesure(self):
        self.trig.off(); time.sleep_us(2); self.trig.on(); time.sleep_us(10); self.trig.off()
        d = time_pulse_us(self.echo, 1, 20000)      # 20 ms max ≈ 3.4 m ; bloque au plus 20 ms
        return d * 0.01715 if d > 0 else None
    def maj(self):
        """À appeler à chaque cycle. Machine à 2 états : tourner le servo, puis mesurer après stabilisation."""
        now = time.ticks_ms()
        if self.etape == "tourner":
            self._angle(self.ANGLES[self.i]); self.t_servo = now; self.etape = "attendre"
        elif self.etape == "attendre" and time.ticks_diff(now, self.t_servo) > 120:   # servo stabilisé
            self.distances[self.ANGLES[self.i]] = self._mesure()
            self.i += self.sens
            if self.i in (0, len(self.ANGLES) - 1): self.sens = -self.sens
            self.etape = "tourner"
    def devant(self):  return self.distances[90]
    def gauche(self):  return min((d for a, d in self.distances.items() if a > 90 and d), default=None)
    def droite(self):  return min((d for a, d in self.distances.items() if a < 90 and d), default=None)
Compromis

Un balayage complet prend 5 × 140 ms = 0,7 s : le robot « voit » chaque direction moins de 2 fois par seconde. Trop lent pour foncer, suffisant à 15 cm/s. La mesure time_pulse_us bloque jusqu’à 20 ms : acceptable dans un cycle de 50 ms, mais c’est le maximum. Trois capteurs ultrason fixes (gauche, centre, droite) sont plus réactifs ; un vrai lidar (séance 19) fait 360° à 10 Hz. Le maj() non bloquant à deux états est déjà une petite machine à états.

01 / Percevoir

Capteurs de ligne et de contact

# TCRT5000 : LED infrarouge + phototransistor.
# Sortie analogique : blanc ≈ faible, noir ≈ élevé (selon module)
from machine import ADC, Pin

class Ligne:
    def __init__(self, broches_adc, seuil=None):
        self.capteurs = [ADC(b) for b in broches_adc]
        self.blanc = [0] * len(self.capteurs)
        self.noir = [65535] * len(self.capteurs)
    def calibrer(self, etape):              # appeler sur blanc, puis sur noir
        v = [c.read_u16() for c in self.capteurs]
        if etape == "blanc": self.blanc = v
        else: self.noir = v
    def lire(self):
        """Renvoie une liste de valeurs 0.0 (blanc) … 1.0 (noir), normalisées."""
        return [max(0.0, min(1.0, (c.read_u16() - b) / max(1, n - b)))
                for c, b, n in zip(self.capteurs, self.blanc, self.noir)]
    def position(self):
        """Position de la ligne : -1 (gauche) … +1 (droite), None si perdue."""
        v = self.lire(); s = sum(v)
        if s < 0.3: return None
        n = len(v)
        return sum(val * (2 * i / (n - 1) - 1) for i, val in enumerate(v)) / s

bumper_g = Pin(12, Pin.IN, Pin.PULL_UP)      # pressé = 0
bumper_d = Pin(13, Pin.IN, Pin.PULL_UP)
  • Un capteur de ligne mesure la réflexion infrarouge à 1 cm du sol : noir absorbe, blanc réfléchit. La lumière ambiante perturbe : calibrer sur place.
  • Trois capteurs alignés donnent une position pondérée de la ligne (barycentre) : bien plus fin que « gauche / centre / droite ».
  • Un bumper est un simple bouton : le dernier recours quand tout le reste a échoué.

Ordre de priorité des capteurs : contact > distance > ligne. Le plus urgent gagne.

Filtrer dans le firmware

Ultrason : médiane des 3 dernières mesures par secteur (tue les échos perdus). Ligne : la normalisation ci-dessus, plus une moyenne sur 2 lectures. Bumper : anti-rebond 20 ms (séance 22). Ce qui sort des capteurs doit être propre avant que la machine à états ne le voie ; sinon elle change d’état sur du bruit.

02 / États

Pourquoi les if ne suffisent pas

# Version naïve : réactive, sans mémoire
def cycle(robot, radar):
    d = radar.devant()
    if d is not None and d < 20:
        robot.deplacer(0, 0.5)        # tourne
    else:
        robot.deplacer(0.5, 0)        # avance

# Dans un couloir en cul-de-sac :
# tourne un peu → voit le mur de côté → avance
# → voit le mur devant → tourne un peu → …
# Le robot oscille indéfiniment.
# Coincé : impossible de « reculer puis
# tourner franchement », car chaque cycle
# oublie le précédent.

Le comportement d’un robot dépend de ce qu’il est en train de faire, pas seulement de ce qu’il voit. « Je suis en train de contourner par la gauche depuis 0,8 s » est une information que les capteurs ne donnent pas.

Une machine à états finis (FSM) rend cette mémoire explicite :

  • Un ensemble fini d’états (EXPLORER, RECULER, TOURNER, BLOQUE…).
  • Des transitions déclenchées par des conditions (capteur, temps écoulé, compteur).
  • Une action par état.
Vous en avez déjà écrit

Le réflexomètre (séance 22), le radar ci-dessus, Mission Mars (victoire/défaite/en cours). Les FSM sont partout : feux tricolores, distributeurs, protocoles réseau, analyseurs de texte, et le cœur des jeux vidéo. En prépa, les automates finis sont un chapitre d’informatique théorique : ce sont les mêmes objets, étudiés mathématiquement.

02 / États

Concevoir la machine : le diagramme d’abord

                    obstacle < 20 cm
   ┌───────────┐ ───────────────────▶ ┌───────────┐
   │ EXPLORER  │                      │  RECULER  │  (0.4 s)
   │ avance    │ ◀─────────────────── │  recule   │
   └───────────┘   après TOURNER      └─────┬─────┘
         ▲                                  │ temps écoulé
         │                                  ▼
         │  cap tourné de 60-120°     ┌───────────┐
         └─────────────────────────── │  TOURNER  │  côté = le plus dégagé
                                      │  pivote   │
   bumper pressé (depuis n'importe    └───────────┘
   quel état) ────▶ RECULER                 │ 3 obstacles en 5 s
                                            ▼
   batterie < 10 % ─────▶ ┌───────────┐  ┌───────────┐
                          │  ARRET    │  │  BLOQUE   │ demi-tour complet,
                          │  stop     │  │  puis     │ puis EXPLORER
                          └───────────┘  └───────────┘
Règles de conception

Chaque état a une action simple et une ou plusieurs sorties clairement définies. Les transitions globales (bumper, batterie) sont vérifiées avant l’état courant : elles ont priorité. Une transition « temps écoulé » évite les états sans fin. Un compteur (« 3 obstacles en 5 s ») détecte les boucles infinies de comportement. Dessinez le diagramme avant de coder : si vous ne pouvez pas le dessiner, vous ne pouvez pas le déboguer.

02 / États

Coder la machine, proprement

Séparer décision et matériel

La classe ne connaît ni PWM ni broche : elle transforme une perception en commande. On la teste avec des assert comme n’importe quelle fonction (séance 08). Le même fichier tourne sur le PC pour les tests, dans le simulateur, et sur le Pico. C’est le principe de la séance 16 (Robot / Capteur / Moteur), poussé au comportement.

03 / Simuler

Le comportement dans un monde simulé

Ce que mesure la simulation

Distance parcourue, collisions, couverture : trois métriques pour comparer des comportements objectivement, sur des dizaines de mondes et de graines aléatoires, en quelques secondes. Le naïf se coince : son capteur frontal voit le mur, il tourne sur place, mais son corps (7 cm de rayon) frotte la boîte pendant la rotation et il reste bloqué — des centaines de cycles en collision, 9 cases explorées. La FSM recule d’abord, puis tourne : zéro collision. Remplacez RAYON = 7.0 par 0.0 (un robot ponctuel) : le naïf s’en sort très bien. La largeur du robot est ce que les if ne voient pas. Modifiez la FSM (durée de recul, angle de rotation, seuil) et mesurez : c’est ainsi qu’on règle un robot, pas en le regardant tourner sur la table. Le lancer de rayon est un lidar simulé rudimentaire. (Exécutez d’abord la cellule précédente : les cellules d’une même page partagent leurs définitions, comme dans un notebook.)

03 / Simuler

Comportements en couches (subsomption)

Subsomption

Rodney Brooks (MIT, 1986) : plutôt qu’une FSM unique, des couches indépendantes, chacune un comportement complet, la plus prioritaire qui « a quelque chose à dire » l’emporte. On ajoute un comportement sans toucher aux autres. Ses robots-insectes ont révolutionné la robotique en montrant qu’un comportement intelligent émerge de réflexes simples empilés. Les aspirateurs robots fonctionnent ainsi. FSM et couches se combinent : chaque couche peut contenir une petite FSM.

04 / Intégrer

Le main.py du robot autonome

# main.py — Pico : boucle à 20 Hz, tout non bloquant
import time, sys, select
from robot import Robot                 # séance 27
from radar import Radar
from comportement import Comportement   # la FSM, testée sur PC

robot, radar, cerveau = Robot(), Radar(16, 17, 18), Comportement()
bumper_g, bumper_d = Pin(12, Pin.IN, Pin.PULL_UP), Pin(13, Pin.IN, Pin.PULL_UP)
poll = select.poll(); poll.register(sys.stdin, select.POLLIN)
actif = False                           # démarre à l'arrêt : sécurité

def lire_batterie(): return 100         # à remplacer : pont diviseur + ADC (séance 21)

print("BOOT")
prochain = time.ticks_ms(); t0 = prochain
try:
    while True:
        while poll.poll(0):                                     # commandes du PC : GO / STOP
            cmd = sys.stdin.readline().strip()
            if cmd == "GO": actif = True; print("OK GO")
            elif cmd == "STOP": actif = False; robot.stop(); print("OK STOP")
        radar.maj()
        t = time.ticks_diff(time.ticks_ms(), t0) / 1000
        perception = dict(devant=radar.devant(), gauche=radar.gauche(), droite=radar.droite(),
                          bumper=not bumper_g.value() or not bumper_d.value(),
                          batterie=lire_batterie(), cap=robot.cap)
        v, w = cerveau.decider(perception, t) if actif else (0.0, 0.0)
        robot.deplacer(v, w); robot.maj()
        x, y, cap = robot.pose()
        print(f"T={int(t * 1000)};E={cerveau.etat};D={perception['devant'] or -1:.0f};X={x:.0f};Y={y:.0f};C={cap:.0f}")
        prochain = time.ticks_add(prochain, 50)
        time.sleep_ms(max(0, time.ticks_diff(prochain, time.ticks_ms())))
finally:
    robot.stop()
Tout y est

Perception (radar non bloquant, bumpers), décision (FSM testée), action (classe Robot avec rampe et odométrie), télémétrie (protocole des séances 20 et 26), commandes PC, cadence fixe, arrêt garanti par finally, démarrage à l’arrêt. Sur le PC : le tableau de bord de la séance 26 affiche l’état de la FSM et la trace odométrique en temps réel. Quand quelque chose ne va pas : le journal, puis le simulateur avec la même perception.

05 / Défis

Défi ★ — Le suiveur de ligne à états

Consigne

Avec la classe Ligne (3 capteurs) : une FSM à quatre états — SUIVRE (correction proportionnelle à la position de la ligne), PERDU_GAUCHE / PERDU_DROITE (tourner du côté où la ligne a été vue en dernier, jusqu’à la retrouver), ARRET (ligne perdue depuis plus de 3 s, ou croisement : les 3 capteurs noirs). Tester sur un circuit fermé au ruban noir. Mesurer le temps au tour à trois vitesses.

Correction (cœur)
class SuiveurLigne:
    def __init__(self): self.etat = "SUIVRE"; self.derniere_pos = 0.0; self.t_perdu = None
    def decider(self, pos, valeurs, t):
        if pos is None:
            if self.etat == "SUIVRE":
                self.etat = "PERDU_GAUCHE" if self.derniere_pos < 0 else "PERDU_DROITE"; self.t_perdu = t
            if t - self.t_perdu > 3: self.etat = "ARRET"
        else:
            if all(v > 0.7 for v in valeurs): self.etat = "ARRET"          # croisement / ligne d'arrivée
            else: self.etat = "SUIVRE"; self.derniere_pos = pos
        if self.etat == "SUIVRE":   return 0.4, -0.8 * pos                  # ligne à droite (pos>0) → tourner à droite (w<0)
        if self.etat == "PERDU_GAUCHE":  return 0.0, 0.5
        if self.etat == "PERDU_DROITE":  return 0.0, -0.5
        return 0.0, 0.0

Le terme −0.8 × pos est un correcteur proportionnel ; à grande vitesse, le robot oscille autour de la ligne et finit par la perdre dans les virages. La séance 29 (PID) règle ce problème. Le signe de w dépend de votre convention (séance 27 : w > 0 tourne à gauche).

05 / Défis

Défi ★★ — Exploration avec carte d’occupation

Consigne

Dans le simulateur, faire construire au robot une carte d’occupation : une grille de 10 × 8 cases de 20 cm, chaque case avec un compteur « vue libre » et « vue occupée » alimenté par les mesures du radar (le rayon jusqu’à l’obstacle traverse des cases libres, la case de l’obstacle est occupée). Après 60 s, afficher la carte (. libre, # occupé, ? inconnu) et la comparer au monde réel. Puis modifier la FSM pour qu’en état EXPLORER, le robot se dirige vers la case inconnue la plus proche (BFS de la séance 18 sur la carte connue) : la « frontière ». Combien de cases sont connues après 60 s, contre l’exploration aléatoire ?

Direction
class Carte:
    def __init__(self, larg, haut, res=20):
        self.res = res; self.nx, self.ny = larg // res + 1, haut // res + 1
        self.libre = [[0] * self.nx for _ in range(self.ny)]; self.occ = [[0] * self.nx for _ in range(self.ny)]
    def observer(self, x, y, angle, d, portee=150):
        for r in range(0, int(d if d else portee), self.res // 2):
            cx, cy = int((x + r * math.cos(angle)) // self.res), int((y + r * math.sin(angle)) // self.res)
            if 0 <= cx < self.nx and 0 <= cy < self.ny: self.libre[cy][cx] += 1
        if d:
            cx, cy = int((x + d * math.cos(angle)) // self.res), int((y + d * math.sin(angle)) // self.res)
            if 0 <= cx < self.nx and 0 <= cy < self.ny: self.occ[cy][cx] += 3
    def etat(self, cx, cy):
        l, o = self.libre[cy][cx], self.occ[cy][cx]
        return "?" if l + o < 2 else ("#" if o > l else ".")

C’est la base du SLAM (cartographie et localisation simultanées) — sauf qu’ici la position vient de l’odométrie parfaite du simulateur ; sur le vrai robot, l’erreur d’odométrie (séance 27) brouille la carte, et il faut corriger la position avec la carte : c’est le vrai SLAM, un sujet de recherche jusqu’aux années 2010, aujourd’hui dans tous les aspirateurs.

05 / Défis

Défi ★★★ — Esprit prépa : vérifier une machine à états

Consigne

Une FSM peut être vérifiée mathématiquement : énumérer tous les états et toutes les perceptions possibles (discrétisées : devant ∈ {proche, loin, aucun}, bumper ∈ {0, 1}, …) et explorer le graphe des transitions (BFS, séance 18).

1. Écrire un vérificateur qui, pour la FSM Comportement, construit le graphe état × perception → état, et répond : tout état est-il atteignable ? Existe-t-il un état sans sortie (puits) autre que ARRET ? Existe-t-il un cycle qui n’inclut jamais EXPLORER (le robot pourrait tourner en rond entre RECULER et TOURNER) ?
2. Ajouter la propriété de sécurité : « si bumper = 1, alors au cycle suivant v ≤ 0 ». Est-elle vraie pour toutes les transitions ? Sinon, corriger la FSM.
3. Chercher ce qu’est le « model checking » et pourquoi Airbus et la NASA l’utilisent.

Direction
import itertools
ETATS = ["EXPLORER", "RECULER", "TOURNER", "BLOQUE", "ARRET"]
PERCEPTIONS = list(itertools.product([10, 50, None], [True, False], [50, 5], ["gauche", "droite"]))   # devant, bumper, batterie, côté dégagé

def transition(etat, percep, depuis, obstacles):
    c = Comportement(); c.etat = etat; c.t_etat = 0.0; c.obstacles_recents = [0.0] * obstacles
    devant, bumper, batterie, cote = percep
    p = dict(devant=devant, bumper=bumper, batterie=batterie, cap=math.radians(90) if depuis > 1 else 0.0,
             gauche=80 if cote == "gauche" else 20, droite=20 if cote == "gauche" else 80)
    v, w = c.decider(p, depuis)
    return c.etat, v

graphe = {e: set() for e in ETATS}; violations = []
for e in ETATS:
    for p in PERCEPTIONS:
        for depuis in (0.1, 2.0):
            for obstacles in (0, 3):
                e2, v = transition(e, p, depuis, obstacles)
                graphe[e].add(e2)
                if p[1] and v > 0: violations.append((e, p, e2, v))
print({e: sorted(s) for e, s in graphe.items()})
print("violations bumper → v>0 :", violations[:3], "…" if len(violations) > 3 else "")

Vous trouverez que l’état TOURNER avec bumper pressé continue de pivoter (v = 0, w ≠ 0) — conforme —, mais qu’EXPLORER avec bumper pressé passe à RECULER dès le cycle courant : la propriété tient. Si elle ne tenait pas, le vérificateur donnerait le contre-exemple exact. Le model checking fait cela sur des millions d’états avec des outils (SPIN, TLA+, UPPAAL) et prouve des propriétés comme « jamais deux feux verts en même temps ». C’est une branche de l’informatique théorique enseignée en master, née des automates de prépa.

06 / Vérification

Qu’apporte une machine à états par rapport à une suite de if sur les capteurs ?

Deux questions supplémentaires

1. Pourquoi tester la FSM sur PC avant le robot ? Parce qu’elle est indépendante du matériel : on vérifie la logique avec des perceptions choisies, vite et sans casse.

2. Quelle couche gagne dans une architecture de subsomption ? La plus prioritaire qui renvoie une commande (la sécurité d’abord).

Référence

Les mots à retenir

MotDéfinition
PerceptionEnsemble des mesures filtrées présentées à la décision.
Machine à états (FSM)États, transitions, actions ; mémoire explicite.
Transition globaleValable depuis tout état (sécurité).
SubsomptionCouches de comportements par priorité.
Lancer de rayonSimulation d’un capteur de distance.
Carte d’occupationGrille de cases libres / occupées / inconnues.
Model checkingVérification exhaustive des propriétés d’un automate.

Pour continuer

Le robot se comporte

Il lui manque la précision : suivre une ligne sans osciller, tenir une vitesse, atteindre un point exactement. Séance suivante : la régulation PID.

À faire chez soi

← Séance 27SommaireSéance 29 : Régulation PID →