Simulation

Le paquet ratbot_sim permet de développer et tester sans robot réel.

Simulateur cinématique léger

Le nœud base_sim imite le firmware, vu du graphe ROS : il s’abonne à base/cmd_vel_safe (la sortie du garde-barrière, comme la vraie carte), intègre une odométrie parfaite et publie base/odom, base/joint_states et la TF ratbot_N/odom → ratbot_N/base_footprint. Le watchdog du firmware est simulé : sans commande pendant 0,5 s, le robot s’arrête. Toute la chaîne réelle est donc en jeu, arrêt d’urgence compris.

source ros2_ws/install/local_setup.bash
ros2 launch ratbot_sim sim.launch.py robot_id:=1

Arguments : robot_id (défaut 1), rviz (défaut true).

Dans RViz : Fixed Frame = ratbot_1/odom, ajouter un affichage RobotModel (topic /ratbot_1/robot_description) et TF.

Piloter au clavier :

ros2 run teleop_twist_keyboard teleop_twist_keyboard \
   --ros-args -r cmd_vel:=/ratbot_1/base/cmd_vel

Tester l’arrêt d’urgence global pendant que le robot roule :

ros2 topic pub --qos-reliability reliable --qos-durability transient_local \
   /ratbot_fleet/emergency_stop std_msgs/msg/Bool "data: true"

Paramètres du nœud base_sim : wheel_radius (0,02597 m) et wheel_separation (0,140 m), rate (50 Hz), cmd_timeout (0,5 s).

Warning

Ces deux valeurs doivent suivre l’URDF, qui suit elle-même la flash de la carte — une seule géométrie, trois endroits qui la recopient. Elles avaient déjà divergé : 0,025 dans le simulateur contre 0,02597 dans l’URDF, soit 4 % d’écart entre le robot simulé et le vrai, relevé le 2026-09-26.

ros2 param get /ratbot_1/ratbot_controller wheel_radius_left

Gazebo

Le paquet ratbot_gazebo fournit la simulation physique sous Gz Sim (Gazebo Fortress, apparié à ROS 2 Humble) : masses et inerties de l’URDF, entraînement différentiel, IMU et lidar simulés. Le simulateur cinématique base_sim reste utile pour les tests rapides sans physique.

Architecture : les annotations du modèle (plugins DiffDrive et JointStatePublisher, capteurs, frictions) vivent dans ratbot_description/urdf/ratbot.gazebo.xacro — le modèle reste avec le modèle ; ratbot_gazebo ne contient que le monde, les launch files et le pont ros_gz (topics Gz ↔ topics ROS 2).

Mondes disponibles (argument world:= de gazebo.launch.py) : ratbot_lab.sdf (sol plat par défaut), tables.sdf (deux tables à pied central, pour le capteur ToF, voir plus bas), enclos.sdf (l’enclos de planches de 2 × 1 m du premier essai réel ; dimensions prévues, à mesurer) et deux labyrinthes fermés (clin d’œil à Hampton Court, pour tester lidar/SLAM/nav) — hampton_signature.sdf (compact, out of the box) et hampton_full.sdf (plus complet). Ils sont générés par ratbot_gazebo/scripts/build_maze.py (segments de mur → SDF). Le script vérifie que le labyrinthe est fermé et solvable (flood-fill) et sait afficher un aperçu ASCII :

python3 src/ratbot_gazebo/scripts/build_maze.py --layout signature --ascii
python3 src/ratbot_gazebo/scripts/build_maze.py --layout full

Le tracé (fonctions build_signature / build_full) et les paramètres (épaisseur, hauteur des murs) s’éditent en tête du script. Lancement :

ros2 launch ratbot_gazebo gazebo.launch.py rviz:=true \
   world:=$(ros2 pkg prefix ratbot_gazebo)/share/ratbot_gazebo/worlds/hampton_signature.sdf
ros2 launch ratbot_gazebo gazebo.launch.py robot_id:=1
# sans le lidar :        use_lidar:=false
# avec RViz en plus :    rviz:=true   (Fixed Frame : ratbot_1/odom)

La simulation respecte le contrat multi-robots (Les topics du robot) : la pile per-robot est celle du vrai robot — robot.launch.py du bringup (agent:=false), donc cmd_vel_gate et robot_state_publisher inclus — et le pont ros_gz expose base/cmd_vel_safe (⬇), base/odom, base/joint_states, imu/data_raw, scan (⬆) sous /ratbot_N/…, avec les frames TF ratbot_N/ sur le /tf partagé. Comme base_sim, Gazebo est un remplaçant transparent du firmware.

Le capteur ToF (VL53L5CX, contrat dans Les topics du robot) n’existe pas dans Gazebo. Le modèle porte un gpu_lidar de 40 × 40 rayons sur 45° × 45° (ratbot.gazebo.xacro), ponté sur sim/tof_rays/points — un topic interne à la simulation. ratbot_tof_sim (ratbot_sim) regroupe ces rayons en 8 × 8 zones et publie sensors/tof/depth, le message de la carte ; la suite (ratbot_tof_cloud, Nav2) est celle du vrai robot.

  • Plusieurs rayons par zone, et la zone garde le plus proche : un obstacle fin qui n’occupe qu’une partie du cône d’une zone la fait répondre, comme sur le vrai capteur. Avec un rayon par zone, la simulation passerait à côté d’un pied que le capteur voit.

  • Bruit et portée de la fiche technique (DS13754, mode 8 × 8 à 15 Hz, tolérances données à 2,7 σ) : σ = 5,5 mm jusqu’à 20 cm, puis 4 % de la distance (cible grise, pièce éclairée) ; 10 % de zones invalides ; portée coupée à 1,5 m (la fiche donne de 0,6 à 3,5 m selon la cible), pour une simulation plus pessimiste que le robot plutôt que l’inverse.

  • Le découpage en zones suit un modèle de caméra (parts égales dans le plan image), celui du capteur n’étant pas documenté : à moins d’un demi-degré près d’un découpage en angles égaux.

Essais du 2026-10-14, décor tables (chiffres et conditions dans la page utilisateur « Le capteur de distance avant ») : sans le ToF, le robot finit sur le pied ou dans le socle ; avec, il passe à côté et atteint le but.

La rampe de la carte, en simulation aussi (2026-10-25). Le plugin de traction de Gazebo suivait un échelon de consigne d’un coup : le turbo de la manette (0,06 → 0,18 m/s) était brutal, et chaque freinage faisait piquer le robot du nez de 1°, assez pour que le capteur ToF prenne le sol pour un obstacle. Il a désormais la rampe du firmware (ROBOT_ACCEL_MS2, 0,5 m/s², et 7 rad/s² en rotation) dans ratbot.gazebo.xacro ; mesuré sur un échelon de 0 à 0,18 m/s : 0,5 m/s² à la montée comme à la descente, tangage nul.

Ce que le capteur simulé a révélé de la simulation elle-même (2026-10-14) :

  • Le parquet des décors n’était qu’un visuel, dessus à 1,2 cm du sol physique : le robot roulait « dedans », et le capteur ToF, qui voit les visuels, prenait ce faux sol pour un obstacle. Il affleure désormais le sol (build_maze.py, et les quatre décors qui en ont un).

  • Les roues de collision étaient des cylindres : couchés, ils touchent le sol par leurs deux bords, et Gazebo voyait un entraxe de 16 cm quand le plugin de traction en compte 14. Sur un tour sur place, les roues comptaient +14,2 % de rotation et l’EKF +3,6 % ; les murs vus par le lidar étaient posés de travers dans la carte de coûts globale, jusqu’à fermer des couloirs de 0,6 m. Elles sont désormais des sphères du rayon de la roue : 0,0 % d’écart. Sans effet sur le vrai robot, seul Gazebo lit les collisions.

Note

Les cartes des décors sont dessinées d’après leur plan (python3 -m ratbot_gazebo.world_map, voir le README de ratbot_maps), et non plus faites au SLAM : celles de rooms et hampton_signature dataient de l’époque des roues à +14 %, avec des murs décalés et, dans le labyrinthe, un passage de 38 cm réduit à 20.

Limite connue : le labyrinthe hampton_signature. Couloirs de 60 cm libres, mais ouvertures de 38 cm entre les anneaux, à prendre en virage. Avec la carte corrigée : 1 réussite sur 2 (213 s). L’échec : le suivi de trajectoire (Regulated Pure Pursuit) coupe le virage dans l’ouverture et s’arrête devant le bord du mur. Les décors rooms (portes de 0,7 m) et tables passent.

Note

Un décor d’après le plan d’un bâtiment : un plan DXF dessiné dans LibreCAD donne un décor Gazebo, puis sa carte par world_map. Calques, commandes et vérifications : Un décor d’après le plan d’un bâtiment. Décor lisv : le laboratoire, 2026-10-25.

Piloter au clavier (à travers le garde-barrière) :

ros2 run teleop_twist_keyboard teleop_twist_keyboard \
   --ros-args -r cmd_vel:=/ratbot_1/base/cmd_vel

L’arrêt d’urgence global fonctionne aussi en simulation :

ros2 topic pub --qos-reliability reliable --qos-durability transient_local \
   /ratbot_fleet/emergency_stop std_msgs/msg/Bool "data: true"

Ajouter d’autres robots dans la simulation qui tourne :

ros2 launch ratbot_gazebo spawn_robot.launch.py robot_id:=2 y:=0.5

Frame ``world``. Les arbres TF des robots sont naturellement disjoints (ratbot_1/odom → …, ratbot_2/odom → …) : rien ne relie les robots entre eux — sur le vrai système, ce lien relève du système hôte (par exemple des caméras fixes qui recalent les poses). En simulation, la vérité terrain est connue : spawn_robot.launch.py publie une TF statique world → ratbot_N/odom à la pose de spawn du robot. RViz (config sim.rviz, Fixed Frame world) peut ainsi afficher tous les robots dans un repère commun.

Note

Différences avec le vrai firmware, assumées à ce stade :

  • le DiffDrive de Gz n’imite pas le watchdog (pas d’arrêt automatique si cmd_vel_safe se tait — base_sim le simule, lui) ;

  • scan n’appartient pas au contrat firmware : sur le vrai robot le lidar est branché sur la Pi (driver rplidar_ros, topic scan dans le namespace) — la simulation reproduit ce nommage.

Banc de navigation

ratbot navtest (module ratbot_gazebo.navtest) rejoue une série fixe d’épreuves de navigation et les mesure contre la vérité de Gazebo, non contre ce que Nav2 annonce. Les épreuves sont décrites dans ratbot_gazebo/navtest.yaml : un décor, des buts enchaînés, une durée maximale en temps simulé.

./scripts/ratbot navtest --list               # les épreuves
./scripts/ratbot navtest porte                # un passage
./scripts/ratbot navtest -n 10                # toutes, 10 fois chacune
./scripts/ratbot navtest labyrinthe           # le test final, seulement nommé

Épreuve

Décor

Ce qu’elle éprouve

porte

rooms

franchir une porte de 0,7 m

retour_porte

rooms

la même, puis repartir de l’encadrement jusqu’au départ

croix

tables

contourner un pied de table en croix, bas : invisible au lidar

disque

tables

contourner un socle en disque

enclos_bout, enclos_retour, enclos_tour

enclos

aller au bout, aller-retour avec demi-tour, tour des quatre coins à 30 cm des planches : les épreuves du premier essai réel

labyrinthe

hampton_signature

couloirs de 60 cm, ouvertures de 38 cm prises en virage. Test final (final: true) : très lent, il ne passe que nommé, une fois tout le reste parfait

Un passage = une simulation neuve (Gazebo sans fenêtre, robot 1 à l’origine, ratbot nav sur la carte du décor), arrêtée à la fin : aucun passage ne dépend du précédent. Chaque passage a son domaine ROS (100 + place, place de 0 à -j − 1) et sa partition Gazebo (ratbot_navtest_<place>) : le banc ne voit pas une simulation ouverte à côté, et n’arrête que ses propres groupes de processus, y compris sur Ctrl-C.

Pendant le passage, un pont ros_gz transmet les poses du monde (/world/<décor>/dynamic_pose/info) sur /ratbot_navtest/truth. À 10 Hz, le banc mesure :

  • la marge : distance entre la caisse du robot (16 × 16 cm, celle de l’URDF et de l’empreinte Nav2) et le contour des obstacles du plan, pris à toutes les hauteurs jusqu’au sommet du lidar (0,5 à 25 cm) — un pied bas compte ; sous 5 mm : contact ;

  • l’erreur de localisation : écart entre la vraie pose et map → ratbot_1/base_footprint. Les cartes des décors étant dessinées d’après le plan, leur repère est celui du monde ;

  • la dérive de l’odométrie : écart entre la vraie pose et ratbot_1/odom → ratbot_1/base_footprint (l’EKF part de la place de départ, l’origine du monde). Comparée à l’erreur de localisation, elle dit laquelle des deux couches se trompe ;

  • les avertissements de collision du controller_server, le nombre de manœuvres de dégagement, et le rapport temps simulé / temps réel.

Gazebo date ses poses à zéro : le banc les date à la réception, et ne les apparie qu’à une pose estimée datée à 0,1 s près ; une vérité qui n’arrive plus depuis 0,5 s n’est plus mesurée. Un passage dont la simulation tourne sous 0,8 fois le temps réel est écarté du bilan : Nav2 et les capteurs n’y ont plus le rythme du réel. Sur un PC à 4 cœurs, deux passages en parallèle (-j 2) tombent à 0,45 : garder -j 1. Un passage figé (Gazebo planté, horloge simulée arrêtée) est arrêté au bout de trois fois sa durée maximale, comptée en temps réel.

Un passage est réussi si Nav2 annonce le but atteint, que le robot est vraiment à 15 cm au plus du but demandé (ARRIVAL_M) et qu’il n’a rien touché. Nav2 juge l’arrivée sur sa propre estimation et sur le but que le planificateur a pu déplacer : à 25 cm (jusqu’au 2026-10-18), un but trop près d’un mur, ramené au point sûr le plus proche, comptait réussi à 17 cm. Un passage dont le processus meurt sans écrire son résultat est noté « run crashed », avec son code de sortie.

Épreuves de l’enclos : enclos_mur et enclos_mur_pres reprennent le contact du 2026-10-16, un but le long du grand côté puis le chemin inverse, donc un demi-tour sur place près de la planche (axe des roues à 17,5 et 12 cm). La simulation n’a jamais reproduit le contact lui-même ; elle mesure la marge pendant le demi-tour. Les résultats vont dans ~/.ros/ratbot_navtest/<date>/ : results.jsonl (une ligne par passage), summary.txt, et les journaux de chaque passage.

Chaque passage écrit aussi trace.jsonl : à 10 Hz, la vraie pose, celle de map et celle d’odom, dès que tout est prêt (arrêt du départ compris). C’est elle qui a montré la forme de l’erreur d’AMCL. --maps DOSSIER remplace les cartes de ratbot_maps (essai d’une autre résolution : world_map --resolution).

Démarrages ratés (corrigé le 2026-10-19) : 3 passages sur 150 finissaient en « Nav2 not active ». Un nœud géré répondait à change_state trop tôt : Fast DDS (Humble) n’attend que 100 ms que le lecteur de réponse du client soit découvert, puis jette la réponse (« failed to send response … (timeout) »), et le lifecycle_manager de Humble l’attend sans délai maximal. En autostart, il crée ses clients et les appelle dans la foulée : sur un PC chargé, la découverte n’a pas suivi. Les gestionnaires sont désormais lancés sans autostart, et ratbot_lifecycle_start (ratbot_nav/lifecycle_start.py) les démarre dans l’ordre (carte, AMCL, navigation) une fois leurs services visibles et la découverte posée (2 s), en jugeant le résultat sur is_active et sans jamais relancer STARTUP. Campagne de contrôle : 40 démarrages sur 40, aucune réponse perdue — cohérent avec la correction, sans la prouver seule (on attendait moins d’un échec). Le même défaut menaçait ratbot nav sur le robot réel.

Passages « run crashed » : une exception dans l’enfant le tuait avant result.json, et sa trace partait dans la sortie du parent (porte n° 5 du 2026-10-18, robot pourtant arrivé au but ; trace perdue). Elle est désormais attrapée : le résultat porte run error: <exception>, et la trace complète va dans error.txt du passage. Deux exceptions possibles après un but ont été supprimées au passage (max d’une liste de caps vide, résultat d’action vide).

Les calculs (contour des obstacles, marge, erreurs, bilan) sont dans navtest_metrics.py, sans ROS, testés au banc (test/test_navtest_metrics.py).