kalman_fixed_agnostic/lib.rs
1// Copyright (C) 2026 Jorge Andre Castro
2//
3// Ce programme est un logiciel libre : vous pouvez le redistribuer et/ou le modifier
4// selon les termes de la Licence Publique Générale GNU telle que publiée par la
5// Free Software Foundation, soit la version 2 de la licence, soit (à votre convention)
6// n'importe quelle version ultérieure.
7
8//! # kalman-fixed-agnostic
9//!
10//! Filtre de Kalman adaptatif en virgule fixe pour systèmes embarqués (`no_std`).
11//!
12//! ## Caractéristiques
13//!
14//! - `#![no_std]` — aucune dépendance à la bibliothèque standard
15//! - Arithmétique entière pure (pas de flottants, pas de `libm`)
16//! - Compatible RP2040 (Cortex-M0+), RP2350 (Cortex-M33) et architectures RISC-V
17//! - Algorithme : Filtre de Kalman scalaire à modèle constant
18//! - Bruit de mesure **R adaptatif** via la formule de König : `Var(X) = E[X²] − E[X]²`
19//! - Temps d'exécution **constant** (déterministe) — idéal pour les noyaux temps réel
20//!
21//! ## Représentation interne Q16.16
22//!
23//! Toutes les grandeurs internes (`x`, `p`, `q`, `r`) sont stockées en format
24//! virgule fixe Q16.16 sur `i64` :
25//! ```text
26//! valeur_réelle = valeur_i64 / 65536.0
27//! ```
28//! L'interface publique (entrée/sortie) reste en `i32` (entiers bruts de capteur).
29//!
30//! ## Algorithme
31//!
32//! À chaque appel à [`AgnosticKalman::update`], le filtre effectue dans l'ordre :
33//!
34//! 1. **Mise à jour du buffer circulaire** — la nouvelle mesure remplace la plus ancienne
35//! 2. **Calcul de R dynamique** — variance de la fenêtre via la formule de König
36//! 3. **Prédiction** — `P ← P + Q`
37//! 4. **Gain de Kalman** — `K = P / (P + R)`
38//! 5. **Correction de l'état** — `x ← x + K·(z − x)`
39//! 6. **Correction de la covariance** — `P ← (1 − K)·P`
40//!
41//! ## Formule de König
42//!
43//! La variance est calculée sans passe double grâce à l'identité :
44//! ```text
45//! Var(X) = E[X²] − E[X]²
46//! ```
47//! Cette formulation est numériquement stable pour des capteurs i32 typiques
48//! (ADC 12 bits, capteurs de température, accéléromètres) et s'exécute en O(N)
49//! avec N = 10 (taille fixe du buffer).
50//!
51//! ## Exemple
52//!
53//! ```rust
54//! use kalman_fixed_agnostic::AgnosticKalman;
55//!
56//! // Signal bruité autour de 1000 avec Q = 1 (filtre très lisse)
57//! let mut k = AgnosticKalman::new(1000, 1);
58//!
59//! // Convergence progressive vers la vraie valeur
60//! let mesures = [1005, 998, 1002, 1010, 995, 1003, 999, 1007, 1001, 1004];
61//! let mut sortie = 0i32;
62//! for &m in &mesures {
63//! sortie = k.update(m);
64//! }
65//!
66//! // La sortie doit être proche de 1000
67//! assert!((sortie - 1000).abs() < 15);
68//! ```
69
70#![no_std]
71#![forbid(unsafe_code)]
72
73/// Taille de la fenêtre glissante pour le calcul de la variance dynamique.
74 pub const WINDOW: usize = 10;
75/// Filtre de Kalman adaptatif en virgule fixe Q16.16.
76///
77/// Le paramètre de bruit de mesure R est calculé dynamiquement à partir
78/// de la variance des dernières mesures (formule de König), ce qui permet
79/// au filtre de s'auto-ajuster à la qualité du signal du capteur.
80pub struct AgnosticKalman {
81 /// Valeur estimée en Q16.16 (valeur_réelle = x >> 16)
82 x: i64,
83 /// Covariance d'erreur en Q16.16
84 p: i64,
85 /// Bruit de processus Q en Q16.16 (confiance dans le modèle)
86 q: i64,
87 /// Fenêtre glissante pour le calcul de la variance dynamique
88 buffer: [i32; WINDOW],
89 /// Index courant dans le buffer circulaire
90 buf_idx: usize,
91 /// Indique si le buffer a été rempli au moins une fois
92 is_warm: bool,
93}
94
95impl AgnosticKalman {
96 /// Initialise un nouveau filtre avec une valeur de départ et un bruit de processus.
97 ///
98 /// # Arguments
99 ///
100 /// * `initial_value` Valeur initiale de l'état (en unités capteur, `i32`)
101 /// * `q` — Bruit de processus : confiance dans la stabilité du système.
102 /// Un `q` faible (ex. `1`) produit un filtre très lisse (réponse lente aux
103 /// changements). Un `q` élevé (ex. `100`) suit plus agressivement les mesures.
104 ///
105 /// # Exemple
106 ///
107 /// ```rust
108 /// use kalman_fixed_agnostic::AgnosticKalman;
109 /// let mut filtre = AgnosticKalman::new(512, 5);
110 /// ```
111 pub fn new(initial_value: i32, q: i32) -> Self {
112 Self {
113 x: (initial_value as i64) << 16,
114 p: 1i64 << 16,
115 q: (q as i64) << 16,
116 buffer: [initial_value; WINDOW],
117 buf_idx: 0,
118 is_warm: false,
119 }
120 }
121
122 /// Calcule la variance dynamique via la formule de König : `E[X²] − E[X]²`.
123 ///
124 /// Cette valeur devient le paramètre R (bruit de mesure) du filtre.
125 /// Saturée à 1 (en Q16.16) pour éviter toute singularité mathématique
126 /// (division par zéro dans le calcul du gain de Kalman).
127 fn calculate_dynamic_r(&self) -> i64 {
128 let mut sum: i64 = 0;
129 let mut sum_sq: i64 = 0;
130
131 for &val in self.buffer.iter() {
132 let v = val as i64;
133 sum += v;
134 sum_sq += v * v;
135 }
136
137 let n = WINDOW as i64;
138 let mean = sum / n;
139 let mean_sq = sum_sq / n;
140 // Var(X) = E[X²] − E[X]²
141 let variance = mean_sq - (mean * mean);
142
143 // Saturation basse à 1 ULP Q16.16 pour éviter K = 1 exact
144 if variance > 1 {
145 variance << 16
146 } else {
147 1i64 << 16
148 }
149 }
150
151 /// Met à jour le filtre avec une nouvelle mesure brute.
152 ///
153 /// Exécute un cycle complet : prédiction → calcul de R → gain → correction.
154 /// Le temps d'exécution est **constant** et **déterministe**.
155 ///
156 /// # Arguments
157 ///
158 /// * `measurement` Valeur brute du capteur (`i32`)
159 ///
160 /// # Retour
161 ///
162 /// Valeur filtrée en `i32` (même échelle que l'entrée).
163 ///
164 /// # Exemple
165 ///
166 /// ```rust
167 /// use kalman_fixed_agnostic::AgnosticKalman;
168 /// let mut k = AgnosticKalman::new(0, 10);
169 /// let v = k.update(42);
170 /// assert!(v >= 0);
171 /// ```
172 pub fn update(&mut self, measurement: i32) -> i32 {
173 // --- Mise à jour du buffer circulaire ---
174 self.buffer[self.buf_idx] = measurement;
175 self.buf_idx = (self.buf_idx + 1) % WINDOW;
176 if self.buf_idx == 0 {
177 self.is_warm = true;
178 }
179
180 // Calcul du paramètre R adaptatif
181 let r = self.calculate_dynamic_r();
182
183 // ÉTAPE 1 : PRÉDICTION modèle à vitesse constante nulle
184 // P ← P + Q
185 self.p = self.p.saturating_add(self.q);
186
187 // ÉTAPE 2 : GAIN DE KALMAN
188 // K = P / (P + R), résultat en Q16.16
189 let p_plus_r = self.p.saturating_add(r);
190 let k = if p_plus_r != 0 {
191 (self.p << 16) / p_plus_r
192 } else {
193 0
194 };
195
196 // ÉTAPE 3 : CORRECTION DE L'ÉTAT
197 // x ← x + K · (z − x)
198 let z = (measurement as i64) << 16;
199 let innovation = z.saturating_sub(self.x);
200 self.x = self.x.saturating_add((k * innovation) >> 16);
201
202 // ÉTAPE 4 : CORRECTION DE LA COVARIANCE
203 // P ← (1 − K) · P
204 let one_q = 1i64 << 16;
205 self.p = (one_q.saturating_sub(k).saturating_mul(self.p)) >> 16;
206 // Saturation basse de P pour éviter la dégénérescence numérique
207 if self.p < 1 {
208 self.p = 1;
209 }
210
211 // Déséchélonnage vers i32
212 (self.x >> 16) as i32
213 }
214
215 /// Indique si le buffer est rempli (au moins [`WINDOW`] mesures reçues).
216 ///
217 /// Avant le premier remplissage, la variance R est calculée sur des copies
218 /// de `initial_value`, ce qui peut produire un R très bas (signal "parfait").
219 /// Les applications critiques peuvent attendre `is_warm()` avant d'utiliser
220 /// la sortie filtrée.
221 pub fn is_warm(&self) -> bool {
222 self.is_warm
223 }
224
225 /// Réinitialise l'état interne sans réallouer.
226 ///
227 /// Utile pour un redémarrage logiciel en place (watchdog, reprise après erreur).
228 pub fn reset(&mut self, initial_value: i32) {
229 self.x = (initial_value as i64) << 16;
230 self.p = 1i64 << 16;
231 self.buffer = [initial_value; WINDOW];
232 self.buf_idx = 0;
233 self.is_warm = false;
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240
241 #[test]
242 fn test_signal_constant_converge() {
243 // Un signal parfaitement constant doit être reproduit exactement après convergence
244 let mut k = AgnosticKalman::new(500, 1);
245 let mut out = 0i32;
246 for _ in 0..50 {
247 out = k.update(500);
248 }
249 assert_eq!(out, 500, "Signal constant : attendu 500, reçu {}", out);
250 }
251
252 #[test]
253 fn test_signal_bruite_lisse() {
254 // Signal bruité autour de 1000 : la sortie doit rester proche de 1000
255 let mut k = AgnosticKalman::new(1000, 1);
256 let bruit = [1005i32, 998, 1002, 1010, 995, 1003, 999, 1007, 1001, 1004];
257 let mut out = 0i32;
258 for _ in 0..5 {
259 for &m in &bruit {
260 out = k.update(m);
261 }
262 }
263 assert!(
264 (out - 1000).abs() < 15,
265 "Signal bruité 1000 : attendu ≈1000, reçu {}",
266 out
267 );
268 }
269
270 #[test]
271 fn test_sortie_strictement_positive_signal_positif() {
272 let mut k = AgnosticKalman::new(100, 10);
273 for i in 0..20 {
274 let out = k.update(100 + i);
275 assert!(out > 0, "Sortie doit être positive, reçu {} à i={}", out, i);
276 }
277 }
278
279 #[test]
280 fn test_monotone_apres_saut() {
281 // Après un saut de valeur, le filtre doit converger progressivement (sans oscillation)
282 let mut k = AgnosticKalman::new(0, 50);
283 // Phase de chauffe à 0
284 for _ in 0..10 {
285 k.update(0);
286 }
287 // Saut à 1000
288 let mut prev = k.update(1000);
289 let mut monotone = true;
290 for _ in 0..20 {
291 let cur = k.update(1000);
292 if cur < prev {
293 monotone = false;
294 break;
295 }
296 prev = cur;
297 }
298 assert!(monotone, "La convergence post-saut doit être monotone croissante");
299 }
300
301 #[test]
302 fn test_is_warm_false_initialement() {
303 let k = AgnosticKalman::new(0, 1);
304 assert!(!k.is_warm(), "Le filtre ne doit pas être chaud à l'initialisation");
305 }
306
307 #[test]
308 fn test_is_warm_apres_window_mesures() {
309 let mut k = AgnosticKalman::new(0, 1);
310 for i in 0..WINDOW {
311 // Pas encore chaud après i mesures (sauf la dernière qui complète le tour)
312 k.update(i as i32);
313 }
314 assert!(k.is_warm(), "Le filtre doit être chaud après {} mesures", WINDOW);
315 }
316
317 #[test]
318 fn test_reset_reinitialise_etat() {
319 let mut k = AgnosticKalman::new(1000, 5);
320 for _ in 0..20 {
321 k.update(2000);
322 }
323 k.reset(0);
324 // Après reset, la première mesure doit démarrer depuis 0
325 let out = k.update(0);
326 assert!(
327 out.abs() < 100,
328 "Après reset à 0, la sortie doit être proche de 0, reçu {}",
329 out
330 );
331 assert!(!k.is_warm(), "Après reset, is_warm doit être false");
332 }
333
334 #[test]
335 fn test_signal_negatif() {
336 // Le filtre doit fonctionner avec des valeurs négatives (ex. capteur bipolaire)
337 let mut k = AgnosticKalman::new(-500, 5);
338 let mut out = 0i32;
339 for _ in 0..30 {
340 out = k.update(-500);
341 }
342 assert!(
343 (out - (-500)).abs() < 20,
344 "Signal constant négatif : attendu ≈-500, reçu {}",
345 out
346 );
347 }
348
349 #[test]
350 fn test_pas_de_saturation_sur_valeur_extreme() {
351 // Vérifie qu'une mesure extrême ne provoque pas de panique (overflow)
352 let mut k = AgnosticKalman::new(0, 1);
353 let _ = k.update(i32::MAX / 2);
354 let _ = k.update(i32::MIN / 2);
355 // Pas de panique = succès
356 }
357
358 #[test]
359 fn test_q_eleve_suit_signal() {
360 // Avec un Q élevé, le filtre doit suivre rapidement un changement de signal
361 let mut k = AgnosticKalman::new(0, 1000);
362 for _ in 0..15 {
363 k.update(5000);
364 }
365 let out = k.update(5000);
366 assert!(
367 (out - 5000).abs() < 200,
368 "Filtre réactif (Q élevé) : attendu ≈5000, reçu {}",
369 out
370 );
371 }
372}