🦀 Rust pour la robotique · temps réel

Chapitre 09
Modules, crates & nalgebra

Objectifs du chapitre

1. Pourquoi découper en modules ?

En C/C++, on sépare le code en .h/.c et on gère la visibilité avec des en-têtes et parfois static. En Rust, l'unité d'organisation s'appelle un module (mod) : elle regroupe des fonctions, structures, énumérations, constantes, et contrôle leur visibilité de façon beaucoup plus fine et vérifiée par le compilateur.

Par défaut, tout est privé au module (et à ses enfants). Le mot-clé pub expose un élément aux modules parents. Il n'y a pas de fichier d'en-tête séparé à maintenir en synchronisation : le compilateur connaît toute l'arborescence des modules directement depuis le code source.

// src/main.rs
mod capteurs {
    // Privé au module `capteurs` : invisible depuis `main`.
    fn calibrer_offset() -> f64 {
        0.02
    }

    // `pub` : visible depuis l'extérieur du module.
    pub fn lire_distance_brute() -> f64 {
        1.234 + calibrer_offset()
    }

    pub struct Lidar {
        pub portee_max_m: f64,
        angle_deg: f64, // champ privé : encapsulation à la C++ avec des accesseurs
    }

    impl Lidar {
        pub fn new(portee_max_m: f64) -> Self {
            Lidar { portee_max_m, angle_deg: 0.0 }
        }

        pub fn angle(&self) -> f64 {
            self.angle_deg
        }
    }
}

fn main() {
    let d = capteurs::lire_distance_brute();
    let lidar = capteurs::Lidar::new(12.0);
    println!("distance = {d}, portée max = {}", lidar.portee_max_m);
    // capteurs::calibrer_offset() ; // ERREUR : fonction privée
    // lidar.angle_deg ;             // ERREUR : champ privé
}

Piège venant du C/C++ : il n'y a pas de règle « un fichier = une unité de compilation indépendante ». Un module Rust peut être défini inline (comme ci-dessus) ou dans un fichier séparé, mais dans les deux cas il fait partie du même arbre de compilation, vérifié en une seule passe par le compilateur. Il n'existe pas non plus de #include qui copie-colle du texte : mod déclare qu'un module existe et où le trouver, use sert seulement à raccourcir des chemins déjà visibles.

2. Chemins : crate::, self::, super::

Rust identifie tout élément par un chemin dans l'arbre de modules, un peu comme un chemin de fichier. Trois racines sont utiles :

mod cinematique {
    pub mod directe {
        pub fn position_effecteur() -> [f64; 3] {
            // Appel vers le module voisin `inverse` via `super::`
            super::inverse::verifie_bornes();
            [0.1, 0.2, 0.3]
        }
    }

    pub mod inverse {
        pub fn verifie_bornes() {
            // ...
        }

        pub fn resoudre() {
            // Chemin absolu explicite : toujours valide, quel que soit
            // l'endroit d'où on écrit ce code.
            let _p = crate::cinematique::directe::position_effecteur();
        }
    }
}

use cinematique::directe::position_effecteur; // raccourci pour `main`

fn main() {
    let p = position_effecteur();
    println!("{p:?}");
}

En pratique, on utilise surtout use en tête de fichier pour importer ce dont on a besoin, et on réserve les chemins complets (crate::…) aux cas où plusieurs éléments importés portent le même nom, ou pour la clarté dans du code générique.

3. Découper un module en fichier séparé

Dès qu'un module grossit, on le sort de main.rs. Deux conventions sont possibles pour un module nommé algebre :

Dans les deux cas, on déclare le module une seule fois dans le parent avec mod algebre; (sans corps de bloc, terminé par un point-virgule) : c'est ce qui indique au compilateur d'aller chercher le fichier correspondant.

// src/main.rs
mod algebre;      // -> cherche src/algebre.rs ou src/algebre/mod.rs
mod cinematique;  // -> cherche src/cinematique.rs ou src/cinematique/mod.rs
mod kalman;       // -> cherche src/kalman.rs ou src/kalman/mod.rs

use algebre::Vecteur3;

fn main() {
    let v = Vecteur3::new(1.0, 0.0, 0.0);
    println!("norme = {}", v.norme());
}
// src/algebre.rs
pub struct Vecteur3 {
    pub x: f64,
    pub y: f64,
    pub z: f64,
}

impl Vecteur3 {
    pub fn new(x: f64, y: f64, z: f64) -> Self {
        Vecteur3 { x, y, z }
    }

    pub fn norme(&self) -> f64 {
        (self.x * self.x + self.y * self.y + self.z * self.z).sqrt()
    }
}

Voici l'arborescence d'un petit projet robotique organisé en modules — c'est celle que nous allons faire évoluer pour le fil rouge du cours :

mon_robot/
├── Cargo.toml
└── src/
    ├── main.rs          // mod algebre; mod cinematique; mod kalman;
    ├── algebre.rs        // Vecteur3, Matrice3, opérations de base
    ├── cinematique.rs     // modèle direct / inverse du bras robotique
    └── kalman.rs          // filtre de Kalman pour la fusion capteurs

Chaque fichier de module peut lui-même contenir des sous-modules avec mod, y compris son propre module de tests #[cfg(test)] mod tests { … } (section 5). Le découpage en fichiers n'affecte ni la visibilité ni la vitesse de compilation de façon significative : c'est avant tout une question de lisibilité et de responsabilité claire par fichier.

4. Crates : bibliothèques, binaires et crates.io

Une crate est l'unité de compilation et de distribution en Rust — l'équivalent grossier d'une bibliothèque statique/dynamique ou d'un exécutable en C/C++, mais gérée par un outil unique : cargo. Un projet cargo new mon_robot génère un binaire (src/main.rs, avec une fonction fn main()) ; cargo new --lib ma_lib génère une bibliothèque (src/lib.rs, sans main, pensée pour être réutilisée par d'autres crates). Un même projet peut avoir les deux à la fois.

crates.io est le registre public officiel de crates (l'équivalent de PyPI ou npm). Pour ajouter une dépendance, deux méthodes équivalentes :

# Cargo.toml — méthode manuelle
[package]
name = "mon_robot"
version = "0.1.0"
edition = "2021"

[dependencies]
nalgebra = "0.33"
# méthode recommandée : cargo add fait la même chose automatiquement
cargo add nalgebra
cargo add nalgebra --features serde-serialize   # avec des fonctionnalités optionnelles

cargo add résout automatiquement la dernière version compatible et met à jour Cargo.toml ainsi que Cargo.lock (le fichier qui fige les versions exactes utilisées, à committer pour un binaire — comparable à un package-lock.json). Pas besoin de chercher un numéro de version à la main.

5. Tests unitaires et calcul numérique

Rust intègre un framework de tests directement dans le langage et dans cargo : pas besoin de dépendance externe comme Google Test en C++. La convention est de placer les tests dans un sous-module tests, annoté #[cfg(test)] pour qu'il ne soit compilé que lors des tests (donc absent du binaire final).

// src/algebre.rs (suite)

pub fn produit_scalaire(a: &[f64; 3], b: &[f64; 3]) -> f64 {
    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}

#[cfg(test)]
mod tests {
    use super::*; // importe tout ce qui est visible dans le module parent

    #[test]
    fn produit_scalaire_orthogonal() {
        let x = [1.0, 0.0, 0.0];
        let y = [0.0, 1.0, 0.0];
        assert_eq!(produit_scalaire(&x, &y), 0.0);
    }

    #[test]
    fn norme_vecteur_unitaire() {
        let v = Vecteur3::new(1.0, 2.0, 2.0);
        // ATTENTION : ne jamais comparer des f64 avec == !
        let attendu = 3.0;
        assert!(
            (v.norme() - attendu).abs() < 1e-9,
            "norme calculée = {}, attendue = {attendu}",
            v.norme()
        );
    }
}

Piège classique : comme en C/C++, comparer deux f64 avec == ou assert_eq! échoue souvent à cause des erreurs d'arrondi en virgule flottante, même quand le calcul est mathématiquement correct. Pour un test numérique, comparez toujours avec une tolérance : assert!((a - b).abs() < epsilon). Un epsilon typique pour des calculs de robotique en f64 est 1e-9 à 1e-6 selon la propagation d'erreurs attendue.

On lance tous les tests d'un projet avec :

cargo test                     # tous les tests
cargo test norme_vecteur       # filtre par nom
cargo test -- --nocapture      # affiche les println! même si le test passe

Pour du code temps réel, les tests unitaires ne remplacent pas la mesure de latence sur cible, mais ils garantissent la correction fonctionnelle des calculs (cinématique, filtrage, contrôle) avant même de déployer sur le robot — un aller-retour bien moins coûteux qu'un test au banc.

6. nalgebra : l'algèbre linéaire prête à l'emploi

nalgebra est le crate de référence pour l'algèbre linéaire en Rust, très utilisé en robotique (cinématique, SLAM, filtrage de Kalman, planification). Il fournit des types à taille fixe connue à la compilation (rapides, sans allocation) et des types à taille dynamique.

[package]
name = "mon_robot"
version = "0.1.0"
edition = "2021"

[dependencies]
nalgebra = "0.33"
use nalgebra::{Matrix3, Vector3};

fn main() {
    // Une matrice de rotation approximative autour de Z (petits angles)
    let r = Matrix3::new(
        0.995, -0.0998, 0.0,
        0.0998,  0.995, 0.0,
        0.0,     0.0,   1.0,
    );
    let p = Vector3::new(1.0, 0.0, 0.5);

    // Produit matrice-vecteur : orientation appliquée à un point
    let p_tourne = r * p;
    println!("point tourné = {p_tourne}");

    // Transpose : pour une matrice de rotation, transpose == inverse
    let r_t = r.transpose();

    // Inverse générale : renvoie Option>, jamais de panique
    // ni d'undefined behavior comme diviser par un déterminant nul en C.
    match r.try_inverse() {
        Some(r_inv) => println!("inverse trouvée, déterminant = {}", r.determinant()),
        None => println!("matrice non inversible (déterminant ≈ 0)"),
    }

    println!("R^T proche de R^-1 ? {}", (r_t - r.try_inverse().unwrap()).norm() < 1e-6);
}
use nalgebra::{Isometry3, Vector3, Rotation3};

fn transformation_bras() {
    // Une transformation homogène = rotation + translation, composables
    // sans jamais construire une matrice 4x4 "à la main".
    let rotation = Rotation3::from_axis_angle(&Vector3::z_axis(), 0.3);
    let translation = Vector3::new(0.2, 0.0, 0.15);
    let t_base_vers_effecteur = Isometry3::from_parts(translation.into(), rotation.into());

    let point_local = Vector3::new(0.05, 0.0, 0.0);
    let point_monde = t_base_vers_effecteur * point_local;

    println!("point dans le repère monde = {point_monde}");

    // Inverse d'une isométrie : toujours définie (contrairement à une
    // matrice quelconque), car une rotation + translation est toujours inversible.
    let t_inverse = t_base_vers_effecteur.inverse();
    println!("retour au repère local = {}", t_inverse * point_monde);
}

Réutilisez nalgebra plutôt que de réécrire vos propres routines d'algèbre linéaire : c'est du code testé, optimisé, et qui couvre déjà l'essentiel (décompositions, résolution de systèmes linéaires, quaternions…). Cela dit, il reste indispensable de comprendre ce que font ces opérations (produit matriciel, déterminant, inversion) pour interpréter correctement les résultats et déboguer un problème numérique — c'est l'objet du chapitre 10, consacré à l'algèbre linéaire appliquée à la robotique.

7. Fil rouge : structurer le projet du cours

À partir de ce chapitre, le projet du fil rouge se découpe en modules dédiés, préfigurant les prochains chapitres :

mon_robot/
├── Cargo.toml           # dépendance : nalgebra = "0.33"
└── src/
    ├── main.rs           # point d'entrée, assemble les modules
    ├── algebre.rs         # wrappers/utilitaires autour de nalgebra
    ├── cinematique.rs      # modèle géométrique du bras (chapitre 10)
    └── kalman.rs            # fusion de capteurs (chapitres suivants)
// src/main.rs
mod algebre;
mod cinematique;
mod kalman;

use nalgebra::{Matrix3, Vector3};

fn main() {
    let m = Matrix3::identity();
    let v = Vector3::new(1.0, 2.0, 3.0);
    println!("m * v = {}", m * v);

    match algebre::inverser(&m) {
        Some(inv) => println!("inverse = {inv}"),
        None => println!("matrice non inversible"),
    }
}
// src/algebre.rs
use nalgebra::Matrix3;

/// Tente d'inverser une matrice 3x3 ; renvoie `None` si elle est singulière
/// (déterminant proche de zéro), sans jamais paniquer.
pub fn inverser(m: &Matrix3<f64>) -> Option<Matrix3<f64>> {
    m.try_inverse()
}

#[cfg(test)]
mod tests {
    use super::*;
    use nalgebra::Matrix3;

    #[test]
    fn identite_est_sa_propre_inverse() {
        let id = Matrix3::identity();
        let inv = inverser(&id).expect("l'identité est toujours inversible");
        assert!((inv - id).norm() < 1e-9);
    }

    #[test]
    fn matrice_singuliere_renvoie_none() {
        let singuliere = Matrix3::new(
            1.0, 2.0, 3.0,
            2.0, 4.0, 6.0, // ligne 2 = 2 * ligne 1 -> déterminant nul
            0.0, 1.0, 1.0,
        );
        assert!(inverser(&singuliere).is_none());
    }
}

Exercices

Exercice 1 — Créer un module et l'appeler depuis main

Créez un module capteurs (dans un fichier src/capteurs.rs) contenant une fonction publique pub fn temperature_moyenne(mesures: &[f64]) -> f64 qui renvoie la moyenne d'un tableau de mesures (0.0 si le tableau est vide). Déclarez le module dans main.rs et appelez la fonction.

Voir la solution
// src/capteurs.rs
pub fn temperature_moyenne(mesures: &[f64]) -> f64 {
    if mesures.is_empty() {
        return 0.0;
    }
    let somme: f64 = mesures.iter().sum();
    somme / mesures.len() as f64
}
// src/main.rs
mod capteurs;

fn main() {
    let mesures = [20.1, 20.4, 19.8, 20.0];
    let moyenne = capteurs::temperature_moyenne(&mesures);
    println!("température moyenne = {moyenne:.2} °C");
}

Le module est déclaré une seule fois avec mod capteurs; dans main.rs, ce qui dit au compilateur d'aller lire src/capteurs.rs. La fonction doit être pub pour être appelable depuis main via le chemin capteurs::temperature_moyenne.

Exercice 2 — Test unitaire avec tolérance sur des f64

Reprenez la fonction temperature_moyenne de l'exercice 1 et ajoutez un module de tests #[cfg(test)] mod tests { … } avec au moins deux tests : un cas normal, et le cas d'un tableau vide. Comparez les résultats flottants avec une tolérance, pas avec ==.

Voir la solution
// src/capteurs.rs (ajout en bas du fichier)
#[cfg(test)]
mod tests {
    use super::*;

    fn presque_egal(a: f64, b: f64) -> bool {
        (a - b).abs() < 1e-9
    }

    #[test]
    fn moyenne_de_quatre_mesures() {
        let mesures = [20.1, 20.4, 19.8, 20.0];
        let m = temperature_moyenne(&mesures);
        assert!(presque_egal(m, 20.075), "moyenne obtenue = {m}");
    }

    #[test]
    fn moyenne_tableau_vide_vaut_zero() {
        let mesures: [f64; 0] = [];
        assert_eq!(temperature_moyenne(&mesures), 0.0);
    }
}

assert_eq!(…, 0.0) est acceptable ici car 0.0 est un cas particulier renvoyé littéralement par le code (pas le résultat d'un calcul flottant accumulé) : il n'y a donc pas d'erreur d'arrondi possible. Dès qu'un résultat provient d'une somme ou d'une division en virgule flottante, on compare toujours avec une tolérance, comme dans moyenne_de_quatre_mesures.

Exercice 3 — Ajouter nalgebra et multiplier deux matrices

Ajoutez nalgebra comme dépendance (via cargo add nalgebra ou directement dans Cargo.toml). Écrivez une fonction qui construit deux Matrix3<f64> et affiche leur produit.

Voir la solution
cargo add nalgebra
use nalgebra::Matrix3;

fn main() {
    let a = Matrix3::new(
        1.0, 2.0, 0.0,
        0.0, 1.0, 3.0,
        4.0, 0.0, 1.0,
    );
    let b = Matrix3::identity() * 2.0; // matrice diagonale (2, 2, 2)

    let produit = a * b;
    println!("A * B =\n{produit}");
}

Matrix3::new prend les coefficients ligne par ligne. Le produit a * b utilise l'opérateur * surchargé par nalgebra pour effectuer un vrai produit matriciel (pas un produit terme à terme).

Exercice 4 — Inverser une matrice avec try_inverse et gérer le None

Écrivez une fonction pub fn inverse_ou_identite(m: &Matrix3<f64>) -> Matrix3<f64> qui renvoie l'inverse de m si elle existe, ou la matrice identité sinon (sans jamais paniquer). Testez-la avec une matrice inversible et une matrice singulière.

Voir la solution
use nalgebra::Matrix3;

pub fn inverse_ou_identite(m: &Matrix3<f64>) -> Matrix3<f64> {
    match m.try_inverse() {
        Some(inv) => inv,
        None => Matrix3::identity(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn matrice_inversible() {
        let m = Matrix3::new(
            2.0, 0.0, 0.0,
            0.0, 2.0, 0.0,
            0.0, 0.0, 2.0,
        );
        let inv = inverse_ou_identite(&m);
        let attendu = Matrix3::identity() * 0.5;
        assert!((inv - attendu).norm() < 1e-9);
    }

    #[test]
    fn matrice_singuliere_donne_identite() {
        let m = Matrix3::new(
            1.0, 1.0, 0.0,
            2.0, 2.0, 0.0, // ligne 2 = 2 * ligne 1
            0.0, 0.0, 1.0,
        );
        let resultat = inverse_ou_identite(&m);
        assert!((resultat - Matrix3::identity()).norm() < 1e-9);
    }
}

try_inverse renvoie un Option<Matrix3<f64>> : c'est l'équivalent sûr d'une fonction C qui renverrait un code d'erreur ou un pointeur nul en cas de matrice singulière, mais ici le compilateur force à traiter le cas None avant d'utiliser le résultat — pas de risque d'oublier la vérification et d'utiliser une matrice invalide par erreur.

Récapitulatif