🦀 Rust pour la robotique · temps réel

Chapitre 10
Algèbre linéaire appliquée

Objectifs du chapitre

Nous entamons la Partie B : les applications. Jusqu'ici on a bâti des outils (types, ownership, modules). À partir de maintenant, on résout de vrais problèmes de robotique, et le premier langage de la robotique, c'est l'algèbre linéaire. Position d'un bras articulé, orientation d'un drone, fusion de capteurs, filtre de Kalman : tout se ramène à des vecteurs et des matrices. Ce chapitre pose des bases mathématiquement rigoureuses puis les traduit en Rust correct.

1. Rappels : vecteurs, matrices, notations

Un vecteur de dimension n est une liste ordonnée de n réels. On le note en colonne, en gras minuscule, avec ses composantes indicées à partir de 1 (convention mathématique ; en Rust on indexera à partir de 0) :

v = (v1, v2, ..., vn)ᵀ ∈ ℝⁿ

Une matrice m×n (m lignes, n colonnes) est un tableau rectangulaire de réels, noté en gras majuscule. L'élément à la ligne i et la colonne j est A[i][j], ou en notation mathématique aij :

| a11 a12 a13 | A = | a21 a22 a23 | A ∈ ℝ^(m×n) (ici m = 3, n = 3) | a31 a32 a33 |

Un vecteur colonne de dimension n est simplement une matrice n×1. La dimension est capitale : la plupart des erreurs (et des panics) viennent de dimensions incompatibles. En robotique on rencontre surtout :

Pourquoi l'algèbre linéaire est-elle partout ?

Deux grandes raisons. D'abord les transformations géométriques : faire tourner un repère, composer les articulations d'un bras, projeter un point de caméra — ce sont des produits matrice-vecteur. Ensuite l'estimation d'état : un filtre (Kalman, moindres carrés) manipule des vecteurs d'état et des matrices de covariance à chaque cycle. Une boucle de contrôle à 1 kHz peut donc effectuer des milliers de produits matriciels par seconde : il faut que ce soit à la fois juste et rapide.

2. Opérations de base

L'addition de deux vecteurs (ou matrices) de mêmes dimensions se fait composante par composante. Le produit par un scalaire λ multiplie chaque composante :

(u + v)i = ui + vi (λ·v)i = λ · vi

Le produit scalaire (dot product) de deux vecteurs de même dimension renvoie un réel. C'est la brique de la projection et de la mesure d'angle :

u · v = Σ (i=1..n) ui·vi = u1·v1 + u2·v2 + ... + un·vn u · v = ‖u‖ · ‖v‖ · cos(θ) (θ = angle entre u et v)

La norme euclidienne (longueur) d'un vecteur se déduit du produit scalaire de v avec lui-même :

‖v‖ = √(v · v) = √( v1² + v2² + ... + vn² )

Un vecteur de norme 1 est dit unitaire. On normalise un vecteur non nul en le divisant par sa norme : v̂ = v / ‖v‖. Utile pour un axe de rotation ou une direction de commande.

3. Produit matrice-vecteur et matrice-matrice

Le produit matrice-vecteur A·v n'est défini que si le nombre de colonnes de A égale la dimension de v. Si A est m×n et v ∈ ℝⁿ, alors A·v ∈ ℝᵐ. Chaque composante du résultat est le produit scalaire d'une ligne de A avec v :

(A·v)i = Σ (j=1..n) aij · vj

Le produit matrice-matrice C = A·B n'est défini que si le nombre de colonnes de A égale le nombre de lignes de B. Si A est m×p et B est p×n, alors C est m×n. L'élément cij est le produit scalaire de la ligne i de A par la colonne j de B :

cij = Σ (k=1..p) aik · bkj (m × p) · (p × n) → (m × n) // les p « du milieu » doivent coïncider

Complexité. Un produit matrice-vecteur m×n coûte O(m·n) multiplications. Un produit matrice-matrice m×p par p×n coûte O(m·p·n) : pour des matrices n×n, c'est O(n³) avec l'algorithme naïf. Pour une 3×3, c'est 27 multiplications — négligeable ; mais dans une boucle temps réel, ces constantes s'additionnent.

Le produit matriciel n'est PAS commutatif : en général A·B ≠ B·A, et l'un peut même être défini quand l'autre ne l'est pas (dimensions). En robotique, l'ordre encode la séquence des transformations : « tourner puis translater » n'est pas « translater puis tourner ». Inversez l'ordre et le robot part ailleurs.

4. Transposée, identité, déterminant, inverse

La transposée Aᵀ échange lignes et colonnes : l'élément (i, j) de Aᵀ est l'élément (j, i) de A. Une matrice m×n devient n×m.

La matrice identité In est carrée, avec des 1 sur la diagonale et des 0 ailleurs. Elle est neutre pour le produit : A·I = I·A = A.

| 1 0 0 | I₃ = | 0 1 0 | | 0 0 1 |

Le déterminant det(A) est un scalaire défini pour les matrices carrées. Il mesure le facteur d'échelle des volumes sous la transformation. Fait clé : A est inversible si et seulement si det(A) ≠ 0.

// Cas 2×2 A = | a b | det(A) = a·d − b·c | c d |
// Cas 3×3 (développement selon la première ligne, règle de Sarrus) A = | a b c | | d e f | | g h i | det(A) = a·(e·i − f·h) − b·(d·i − f·g) + c·(d·h − e·g)

La matrice inverse A⁻¹ (quand elle existe) vérifie A·A⁻¹ = A⁻¹·A = I. Elle « annule » la transformation. Formule 2×2 explicite (l'inverse existe ssi det ≠ 0) :

A = | a b | A⁻¹ = (1 / det(A)) · | d −b | | c d | | −c a |

Pour la 3×3, l'inverse vaut (1/det) fois la transposée de la matrice des cofacteurs (comatrice). C'est calculable à la main mais fastidieux et numériquement fragile — d'où la section suivante.

5. Résoudre plutôt qu'inverser

On veut souvent trouver x tel que A·x = b (par exemple : quelles vitesses articulaires donnent telle vitesse d'effecteur ?). La tentation est d'écrire x = A⁻¹·b. Mauvaise idée en pratique.

Règle : n'inversez une matrice que si vous avez réellement besoin de la matrice inverse elle-même (rare). Pour résoudre un système, utilisez un solveur. On y revient au chapitre sur la cinématique inverse.

6. Approche 1 — une Mat3 « maison »

Pour comprendre ce qui se passe sous le capot, implémentons une matrice 3×3 avec un tableau [[f64; 3]; 3] — trois lignes de trois f64. On choisit f64 et pas f32 : la précision double limite l'accumulation d'erreurs dans les produits et les déterminants.

/// Matrice 3×3 stockée en lignes : data[ligne][colonne].
#[derive(Debug, Clone, Copy, PartialEq)]
struct Mat3 {
    data: [[f64; 3]; 3],
}

impl Mat3 {
    /// Constructeur direct à partir d'un tableau 3×3.
    fn new(data: [[f64; 3]; 3]) -> Self {
        Mat3 { data }
    }

    /// Matrice identité I₃.
    fn identity() -> Self {
        Mat3 {
            data: [
                [1.0, 0.0, 0.0],
                [0.0, 1.0, 0.0],
                [0.0, 0.0, 1.0],
            ],
        }
    }

    /// Produit matrice-vecteur : A·v, avec v ∈ ℝ³.
    /// Chaque composante = produit scalaire d'une ligne par v.
    fn mul_vec(&self, v: [f64; 3]) -> [f64; 3] {
        let mut out = [0.0; 3];
        for i in 0..3 {
            for j in 0..3 {
                out[i] += self.data[i][j] * v[j];
            }
        }
        out
    }

    /// Produit matrice-matrice : C = A·B.
    /// c[i][j] = Σ_k a[i][k] · b[k][j]
    fn mul_mat(&self, other: &Mat3) -> Mat3 {
        let mut out = [[0.0; 3]; 3];
        for i in 0..3 {
            for j in 0..3 {
                for k in 0..3 {
                    out[i][j] += self.data[i][k] * other.data[k][j];
                }
            }
        }
        Mat3 { data: out }
    }

    /// Transposée : (Aᵀ)[i][j] = A[j][i].
    fn transpose(&self) -> Mat3 {
        let mut out = [[0.0; 3]; 3];
        for i in 0..3 {
            for j in 0..3 {
                out[i][j] = self.data[j][i];
            }
        }
        Mat3 { data: out }
    }

    /// Déterminant 3×3 (règle de Sarrus / cofacteurs 1re ligne).
    fn det(&self) -> f64 {
        let m = &self.data;
        m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
            - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
            + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
    }

    /// Inverse : renvoie None si la matrice est (quasi) singulière.
    /// A⁻¹ = (1/det) · comatriceᵀ.
    fn inverse(&self) -> Option {
        let d = self.det();
        // Seuil pour éviter les divisions par un déterminant ~0.
        if d.abs() < 1e-12 {
            return None;
        }
        let inv_d = 1.0 / d;
        let m = &self.data;

        // Cofacteurs, déjà transposés (adjugate).
        let mut out = [[0.0; 3]; 3];
        out[0][0] =  (m[1][1] * m[2][2] - m[1][2] * m[2][1]) * inv_d;
        out[0][1] = -(m[0][1] * m[2][2] - m[0][2] * m[2][1]) * inv_d;
        out[0][2] =  (m[0][1] * m[1][2] - m[0][2] * m[1][1]) * inv_d;
        out[1][0] = -(m[1][0] * m[2][2] - m[1][2] * m[2][0]) * inv_d;
        out[1][1] =  (m[0][0] * m[2][2] - m[0][2] * m[2][0]) * inv_d;
        out[1][2] = -(m[0][0] * m[1][2] - m[0][2] * m[1][0]) * inv_d;
        out[2][0] =  (m[1][0] * m[2][1] - m[1][1] * m[2][0]) * inv_d;
        out[2][1] = -(m[0][0] * m[2][1] - m[0][1] * m[2][0]) * inv_d;
        out[2][2] =  (m[0][0] * m[1][1] - m[0][1] * m[1][0]) * inv_d;
        Some(Mat3 { data: out })
    }
}

fn main() {
    let a = Mat3::new([
        [2.0, 0.0, 1.0],
        [1.0, 3.0, 2.0],
        [1.0, 0.0, 1.0],
    ]);

    println!("det(A) = {}", a.det());        // det(A) = 3
    let v = a.mul_vec([1.0, 2.0, 3.0]);
    println!("A·v = {:?}", v);               // A·v = [5.0, 13.0, 4.0]

    match a.inverse() {
        Some(inv) => {
            // Vérification : A·A⁻¹ doit valoir (numériquement) l'identité.
            let prod = a.mul_mat(&inv);
            println!("A·A⁻¹ ≈ {:?}", prod.data);
        }
        None => println!("Matrice singulière, pas d'inverse."),
    }
}

Notez le retour Option<Mat3> : c'est la façon idiomatique en Rust d'exprimer « peut ne pas exister ». Le C renverrait un code d'erreur ou écraserait un pointeur ; ici, l'absence d'inverse est dans le type, et le compilateur force l'appelant à traiter le cas None.

Ne comparez jamais un déterminant à 0.0 exact. Avec des f64, un déterminant théoriquement nul ressort à 1e-16 près. On teste d.abs() < epsilon avec un seuil adapté à l'échelle du problème.

7. Approche 2 — nalgebra (celle qu'on utilisera)

La Mat3 maison est pédagogique, mais en production on utilise nalgebra. Elle offre des types de taille fixe (Matrix3, Vector3, Matrix4…), des opérateurs surchargés, des décompositions robustes et une inversion numériquement fiable via try_inverse (qui renvoie Option, exactement notre convention).

use nalgebra::{Matrix3, Vector3};

fn main() {
    // Attention : Matrix3::new remplit LIGNE par ligne (row-major à la saisie),
    // même si le stockage interne de nalgebra est column-major.
    let a = Matrix3::new(
        2.0, 0.0, 1.0,
        1.0, 3.0, 2.0,
        1.0, 0.0, 1.0,
    );
    let v = Vector3::new(1.0, 2.0, 3.0);

    // Opérateurs naturels : produit matrice-vecteur, matrice-matrice.
    let av: Vector3<f64> = a * v;          // [5, 13, 4]
    let a2: Matrix3<f64> = a * a;           // produit matriciel
    let at = a.transpose();
    let d = a.determinant();               // 3.0

    println!("A·v = {}", av);
    println!("det(A) = {}", d);

    // Inversion sûre : Option.
    match a.try_inverse() {
        Some(inv) => {
            let check = a * inv;           // ≈ identité
            println!("A·A⁻¹ ≈\n{}", check);
        }
        None => println!("A n'est pas inversible."),
    }

    // Pour RÉSOUDRE A·x = b, on ne fait pas x = a.try_inverse() * b :
    // on utilise une décomposition, plus stable.
    let b = Vector3::new(5.0, 13.0, 4.0);
    if let Some(x) = a.lu().solve(&b) {
        println!("x = {}", x);             // ≈ [1, 2, 3]
    }
}

En quelques lignes on obtient produit, transposée, déterminant, inverse et un solveur LU. C'est plus court, plus rapide (code optimisé, éventuellement vectorisé) et plus sûr numériquement que notre version maison. La Mat3 reste utile pour comprendre ; nalgebra pour produire.

try_inverse suit exactement la même logique que notre inverse -> Option : Some si inversible, None sinon. Le pattern Option est le fil rouge de la gestion d'erreur en Rust — vous le retrouverez partout.

Temps réel : taille statique sur la pile vs dynamique sur le tas. Matrix3, Matrix4 et Vector3 ont une taille connue à la compilation : ils sont alloués sur la pile, sans malloc, sans indirection, et le compilateur peut dérouler et vectoriser les boucles. À l'inverse, DMatrix / DVector ont une taille dynamique, stockée sur le tas : allocation, cache moins prévisible, coût non déterministe — inacceptable dans une boucle de contrôle à cadence fixe. Règle : dans la boucle temps réel, n'utilisez que des tailles statiques (Matrix3/Matrix4). Réservez DMatrix à l'initialisation ou au code hors boucle critique.

Exercices

Exercice 1 — Produit scalaire et norme

Écris deux fonctions dot(u: [f64; 3], v: [f64; 3]) -> f64 et norm(v: [f64; 3]) -> f64. La norme doit s'exprimer à partir de dot. Teste sur u = [1, 0, 0], v = [0, 2, 0] (produit scalaire nul, vecteurs orthogonaux) et vérifie que norm([3, 4, 0]) == 5.

Voir la solution
fn dot(u: [f64; 3], v: [f64; 3]) -> f64 {
    let mut s = 0.0;
    for i in 0..3 {
        s += u[i] * v[i];
    }
    s
}

/// ‖v‖ = √(v · v)
fn norm(v: [f64; 3]) -> f64 {
    dot(v, v).sqrt()
}

fn main() {
    let u = [1.0, 0.0, 0.0];
    let v = [0.0, 2.0, 0.0];
    println!("u·v = {}", dot(u, v));      // 0  (orthogonaux)
    println!("‖(3,4,0)‖ = {}", norm([3.0, 4.0, 0.0])); // 5
}

norm réutilise dot : moins de code, moins de risque d'erreur. La méthode .sqrt() est fournie par le type f64.

Exercice 2 — Produit matrice 2×2 par vecteur (maison)

Définis une struct Mat2 { data: [[f64; 2]; 2] } avec une méthode mul_vec(&self, v: [f64; 2]) -> [f64; 2]. Vérifie sur la matrice de rotation d'angle 90° [[0, -1], [1, 0]] appliquée à [1, 0] : on doit obtenir [0, 1].

Voir la solution
#[derive(Debug, Clone, Copy)]
struct Mat2 {
    data: [[f64; 2]; 2],
}

impl Mat2 {
    fn mul_vec(&self, v: [f64; 2]) -> [f64; 2] {
        let mut out = [0.0; 2];
        for i in 0..2 {
            for j in 0..2 {
                out[i] += self.data[i][j] * v[j];
            }
        }
        out
    }
}

fn main() {
    // Rotation de +90° dans le plan.
    let rot = Mat2 { data: [[0.0, -1.0], [1.0, 0.0]] };
    let r = rot.mul_vec([1.0, 0.0]);
    println!("{:?}", r); // [0.0, 1.0]  → l'axe x devient l'axe y
}

Le motif est identique à Mat3::mul_vec : chaque composante de sortie est le produit scalaire d'une ligne par v. Une rotation de +90° envoie bien x sur y.

Exercice 3 — Déterminant et inverse d'une 2×2 avec Option

Ajoute à Mat2 une méthode det(&self) -> f64 et une méthode inverse(&self) -> Option<Mat2> qui renvoie None si le déterminant est (quasi) nul. Applique la formule 2×2 explicite. Teste sur une matrice inversible et sur une matrice singulière (deux lignes proportionnelles).

Voir la solution
impl Mat2 {
    fn det(&self) -> f64 {
        // det = a·d − b·c
        self.data[0][0] * self.data[1][1]
            - self.data[0][1] * self.data[1][0]
    }

    fn inverse(&self) -> Option {
        let d = self.det();
        if d.abs() < 1e-12 {
            return None; // singulière
        }
        let inv_d = 1.0 / d;
        let a = self.data[0][0];
        let b = self.data[0][1];
        let c = self.data[1][0];
        let dd = self.data[1][1];
        // A⁻¹ = (1/det) · [[ d, −b], [−c, a]]
        Some(Mat2 {
            data: [
                [ dd * inv_d, -b * inv_d],
                [ -c * inv_d,  a * inv_d],
            ],
        })
    }
}

fn main() {
    let m = Mat2 { data: [[4.0, 7.0], [2.0, 6.0]] };
    println!("det = {}", m.det());        // 10
    println!("{:?}", m.inverse());        // Some(...)

    let sing = Mat2 { data: [[1.0, 2.0], [2.0, 4.0]] };
    println!("{:?}", sing.inverse());     // None (det = 0)
}

Le seuil 1e-12 évite d'inverser une matrice numériquement singulière. Le type de retour Option rend l'échec impossible à ignorer.

Exercice 4 — Refaire l'inverse avec nalgebra et comparer

Reprends la matrice de l'exercice 3 ([[4, 7], [2, 6]]) avec un Matrix2<f64> de nalgebra. Calcule son inverse avec try_inverse() et compare, coefficient par coefficient, avec ta Mat2::inverse. Vérifie que l'écart est inférieur à 1e-12.

Voir la solution
use nalgebra::Matrix2;

fn main() {
    // nalgebra : saisie ligne par ligne.
    let m = Matrix2::new(
        4.0, 7.0,
        2.0, 6.0,
    );

    let inv = m.try_inverse().expect("matrice inversible");
    println!("nalgebra inverse =\n{}", inv);

    // Inverse attendue (calcul maison) :
    //   det = 4·6 − 7·2 = 10
    //   A⁻¹ = 1/10 · [[6, −7], [−2, 4]]
    let attendu = [[0.6, -0.7], [-0.2, 0.4]];

    for i in 0..2 {
        for j in 0..2 {
            let ecart = (inv[(i, j)] - attendu[i][j]).abs();
            assert!(ecart < 1e-12, "écart trop grand en ({i},{j})");
        }
    }
    println!("Les deux inverses coïncident à 1e-12 près.");

    // Contrôle final : A·A⁻¹ ≈ identité.
    println!("A·A⁻¹ =\n{}", m * inv);
}

On indexe un élément de nalgebra avec un tuple : inv[(i, j)]. Résultat identique à la version maison — mais try_inverse reste préférable, car il est plus robuste sur les cas mal conditionnés et se généralise aux dimensions supérieures. La comparaison se fait avec une tolérance, jamais avec == sur des flottants.

Récapitulatif