Skip to main content

flight_solver/rls/
standard.rs

1// Hot-path numerical code: explicit index loops match the C reference
2// algorithm and produce identical codegen under LTO. Iterator style
3// would obscure the matrix-index correspondence with no perf benefit.
4#![allow(clippy::needless_range_loop, clippy::manual_memcpy)]
5
6//! Standard (covariance-form) Recursive Least Squares.
7//!
8//! Two variants:
9//!
10//! - [`Rls<N, D>`] — Standard RLS with `D`-dimensional observation. Uses
11//!   Cholesky decomposition of the `D × D` innovation matrix `M` when `D > 1`.
12//!   Model: `y = A x` where `x ∈ ℝⁿ`, `y ∈ ℝᵈ`, `A ∈ ℝᵈˣⁿ`.
13//!
14//! - [`RlsParallel<N, P>`] — `P` independent single-output RLS instances
15//!   sharing one covariance matrix. The regressor `a ∈ ℝⁿ` is a vector
16//!   (scalar observation per output), so the denominator is a scalar—no
17//!   Cholesky required. Model: `yᵀ = a X` where `X ∈ ℝⁿˣᵖ`.
18//!
19//! Both include the numerical guards from the indiflight C reference:
20//! covariance explosion detection, order decrement limiting, and diagonal
21//! clamping.
22
23use nalgebra::{Const, DimName, OMatrix, OVector};
24
25use super::types::{CovarianceGuards, ExitCode, UpdateStats};
26
27// ═══════════════════════════════════════════════════════════════════════════
28//  Rls<N, D> — standard RLS with D-dimensional observation
29// ═══════════════════════════════════════════════════════════════════════════
30
31/// Standard RLS maintaining the covariance matrix `P` directly.
32///
33/// Supports multi-dimensional observations (`D > 1`) via Cholesky
34/// decomposition of the `D × D` innovation matrix `M = λI + A P Aᵀ`.
35///
36/// # Model
37///
38/// ```text
39/// y = A x    where  y ∈ ℝᵈ,  x ∈ ℝⁿ,  A ∈ ℝᵈˣⁿ
40/// ```
41///
42/// The regressor matrix is passed as `Aᵀ ∈ ℝⁿˣᵈ` (column-major, matching
43/// the indiflight C convention).
44///
45/// # Type parameters
46///
47/// - `N` — number of parameters (regressor dimension)
48/// - `D` — observation dimension (number of outputs per sample)
49///
50/// # Example
51///
52/// ```no_run
53/// use flight_solver::rls::{Rls, CovarianceGuards};
54/// use nalgebra::SMatrix;
55///
56/// // 3 parameters, 2-dimensional observations
57/// let mut rls = Rls::<3, 2>::new(1e2, 0.995, CovarianceGuards::default());
58///
59/// let a_t = SMatrix::<f32, 3, 2>::new(
60///     0.034, 0.227,
61///     0.135, -0.436,
62///     -0.169, -0.059,
63/// );
64/// let y = nalgebra::SVector::<f32, 2>::new(0.338, -0.165);
65///
66/// let stats = rls.update(&a_t, &y);
67/// ```
68#[derive(Clone)]
69pub struct Rls<const N: usize, const D: usize> {
70    /// Parameter vector `x ∈ ℝⁿ`.
71    x: OVector<f32, Const<N>>,
72    /// Covariance matrix `P ∈ ℝⁿˣⁿ` (symmetric positive definite).
73    p: OMatrix<f32, Const<N>, Const<N>>,
74    /// Forgetting factor `λ ∈ (0, 1]`.
75    lambda: f32,
76    /// Number of samples processed.
77    samples: u32,
78    /// Numerical guard configuration.
79    guards: CovarianceGuards,
80}
81
82impl<const N: usize, const D: usize> Rls<N, D>
83where
84    Const<N>: DimName,
85    Const<D>: DimName,
86{
87    /// Create a new standard RLS with initial covariance `P = γ I`.
88    ///
89    /// # Arguments
90    ///
91    /// - `gamma` — initial covariance diagonal (higher = more uncertain = faster learning)
92    /// - `lambda` — forgetting factor in `(0, 1]` (lower = faster forgetting of old data)
93    /// - `guards` — numerical guard configuration
94    pub fn new(gamma: f32, lambda: f32, guards: CovarianceGuards) -> Self {
95        let mut p = OMatrix::<f32, Const<N>, Const<N>>::zeros();
96        for i in 0..N {
97            p[(i, i)] = gamma;
98        }
99        Self {
100            x: OVector::<f32, Const<N>>::zeros(),
101            p,
102            lambda,
103            samples: 0,
104            guards,
105        }
106    }
107
108    /// Process one observation and update parameter estimate.
109    ///
110    /// # Arguments
111    ///
112    /// - `a_t` — regressor matrix transposed, `Aᵀ ∈ ℝⁿˣᵈ` (column-major).
113    ///   Each column is one regressor vector.
114    /// - `y` — observation vector `y ∈ ℝᵈ`
115    ///
116    /// # Algorithm
117    ///
118    /// 1. Covariance explosion check (temporarily increase λ if `P[i,i] > cov_max`)
119    /// 2. Form `M = λI + A P Aᵀ` and Cholesky-solve for `K = P Aᵀ M⁻¹`
120    /// 3. Order decrement limiting on `trace(K A P)`
121    /// 4. Covariance update: `P ← (KAPmult · P − P Aᵀ Kᵀ) / λ`
122    /// 5. Parameter update: `x ← x + K (y − A x)`
123    #[inline]
124    pub fn update(
125        &mut self,
126        a_t: &OMatrix<f32, Const<N>, Const<D>>,
127        y: &OVector<f32, Const<D>>,
128    ) -> UpdateStats {
129        self.samples += 1;
130
131        // ── Step 1: Covariance explosion detection ──────────────────────
132        let mut lam = self.lambda;
133        let mut trace_p: f32 = 0.0;
134        let mut exit_code = ExitCode::Success;
135        for i in 0..N {
136            trace_p += self.p[(i, i)];
137            if self.p[(i, i)] > self.guards.cov_max {
138                lam = 1.0 + 0.1 * (1.0 - self.lambda);
139                exit_code = ExitCode::CovarianceExplosion;
140            }
141        }
142
143        // ── Step 2: Compute P Aᵀ (n×d) ─────────────────────────────────
144        // PAT[i,j] = Σ_k P[i,k] * AT[k,j]
145        let mut pat = [[0.0f32; D]; N]; // pat[row][col], row-major for cache
146        for j in 0..D {
147            for i in 0..N {
148                let mut sum = 0.0f32;
149                for k in 0..N {
150                    sum += self.p[(k, i)] * a_t[(k, j)];
151                }
152                pat[i][j] = sum;
153            }
154        }
155
156        // ── Step 3: Form M = λI + Aᵀᵀ P Aᵀ = λI + (P Aᵀ)ᵀ Aᵀ ────────
157        // M is d×d symmetric
158        let mut m = [[0.0f32; D]; D];
159        for i in 0..D {
160            m[i][i] = lam;
161        }
162        for i in 0..D {
163            for j in 0..D {
164                let mut sum = 0.0f32;
165                for k in 0..N {
166                    sum += pat[k][i] * a_t[(k, j)];
167                }
168                m[i][j] += sum;
169            }
170        }
171
172        // ── Step 4: Cholesky decomposition M = UᵀU and solve K ─────────
173        // Matches indiflight's chol(): upper triangular U where M = UᵀU
174        // Storage: u[i][j] = U(j, i) in mathematical notation
175        // (indiflight uses column-major U[i*d+j] → element at col=i, row=j)
176        let mut u = [[0.0f32; D]; D];
177        let mut i_diag = [0.0f32; D];
178        for i in 0..D {
179            for j in 0..=i {
180                let mut s = 0.0f32;
181                for k in 0..j {
182                    s += u[i][k] * u[j][k];
183                }
184                if i == j {
185                    u[i][j] = libm::sqrtf(m[i][i] - s);
186                    i_diag[j] = 1.0 / u[i][j];
187                } else {
188                    u[i][j] = i_diag[j] * (m[i][j] - s);
189                }
190            }
191        }
192
193        // AP = (P Aᵀ)ᵀ = A P  (d×n), stored as ap[col][row] column-major
194        // ap[col_n][row_d] = pat[col_n][row_d] transposed
195        // In indiflight: AP[row + col*d] = PAT[col + row*n]
196
197        // Solve Kᵀ = M⁻¹ A P  column by column via Cholesky forward/backward sub.
198        // KT is d×n: kt[col_n][row_d]
199        let mut kt = [[0.0f32; D]; N];
200        for col in 0..N {
201            // RHS for this column: AP[:,col] = PAT[col,:] (d elements)
202            let mut b = [0.0f32; D];
203            for row in 0..D {
204                b[row] = pat[col][row]; // AP[row, col] = PAT[col][row]
205            }
206
207            // Forward substitution: Uᵀ y = b
208            // Uᵀ[j,k] = U[k,j]. Cholesky stores u[i][j] = U[i*d+j] which
209            // is element at (col=i, row=j). So U(row=k, col=j) = u[j][k].
210            // For forward sub: need Uᵀ(j,k) for k < j, i.e. U(k,j) = u[j][k].
211            let mut x_sol = [0.0f32; D];
212            for j in 0..D {
213                let mut t = b[j];
214                for k in 0..j {
215                    t -= u[j][k] * x_sol[k];
216                }
217                x_sol[j] = t * i_diag[j];
218            }
219
220            // Backward substitution: U x = y
221            // Need U(j,k) for k > j, i.e. u[k][j].
222            for j in (0..D).rev() {
223                let mut t = x_sol[j];
224                for k in (j + 1)..D {
225                    t -= u[k][j] * x_sol[k];
226                }
227                x_sol[j] = t * i_diag[j];
228            }
229
230            for row in 0..D {
231                kt[col][row] = x_sol[row];
232            }
233        }
234
235        // ── Step 5: Order decrement limiting ────────────────────────────
236        // trace(K A P) = trace(PAT · KT) = Σ_i dot(AP_col_i, KT_col_i)
237        // where AP_col_i[row_d] = pat[i][row_d] and KT_col_i[row_d] = kt[i][row_d]
238        let mut trace_kap: f32 = 0.0;
239        for i in 0..N {
240            for j in 0..D {
241                trace_kap += pat[i][j] * kt[i][j];
242            }
243        }
244
245        let mut kap_mult: f32 = 1.0;
246        if trace_kap > self.guards.cov_min {
247            kap_mult = ((1.0 - self.guards.max_order_decrement) * (trace_p / trace_kap)).min(1.0);
248        }
249
250        // ── Step 6: Covariance update ───────────────────────────────────
251        // P = (KAPmult · P − PAT · KT) / λ
252        // Matching indiflight: SGEMM(n, n, d, PAT, KT, P, -KAPmult, -ilam)
253        //   → P = (-1/λ)((-KAPmult)·P + PAT·KT)
254        //   → P = (KAPmult·P − PAT·KT) / λ
255        // Fused form: P[r,c] = (kap/λ)*P[r,c] - (1/λ)*Σ_k pat[r][k]*kt[c][k]
256        let kap_ilam = kap_mult / lam;
257        let neg_ilam = -1.0 / lam;
258        for col in 0..N {
259            for row in 0..N {
260                let mut rank_d = 0.0f32;
261                for k in 0..D {
262                    rank_d += pat[row][k] * kt[col][k];
263                }
264                self.p[(row, col)] = kap_ilam * self.p[(row, col)] + neg_ilam * rank_d;
265            }
266        }
267
268        // ── Step 7: Compute error and update parameters ─────────────────
269        // e = y − A x = y − Aᵀᵀ x
270        // eᵀ = yᵀ − xᵀ Aᵀ  →  e[j] = y[j] − Σ_k x[k] · AT[k,j]
271        let mut e = [0.0f32; D];
272        for j in 0..D {
273            let mut sum = 0.0f32;
274            for k in 0..N {
275                sum += self.x[k] * a_t[(k, j)];
276            }
277            e[j] = y[j] - sum;
278        }
279
280        // dx = K e = Kᵀᵀ e  →  dx[i] = Σ_j KT[i][j] · e[j]
281        // But KT is stored as kt[col_n][row_d], and K = KTᵀ.
282        // dx[i] = Σ_j K[i,j] · e[j] = Σ_j KT[j,i] · e[j]
283        // With our storage: KT[j,i] = kt[j][i]... hmm.
284        // Actually: kt[col_n][row_d] stores KT which is d×n.
285        // KT(row_d, col_n) → kt[col_n][row_d]. So KT[j_d, i_n] = kt[i_n][j_d].
286        // dx[i] = Σ_j KT(j, i) · e[j] = Σ_j kt[i][j] · e[j]
287        for i in 0..N {
288            let mut dx = 0.0f32;
289            for j in 0..D {
290                dx += kt[i][j] * e[j];
291            }
292            self.x[i] += dx;
293        }
294
295        UpdateStats {
296            exit_code,
297            samples: self.samples,
298        }
299    }
300
301    /// Current parameter estimate `x ∈ ℝⁿ`.
302    #[inline]
303    pub fn params(&self) -> &OVector<f32, Const<N>> {
304        &self.x
305    }
306
307    /// Mutable access to the parameter estimate.
308    #[inline]
309    pub fn params_mut(&mut self) -> &mut OVector<f32, Const<N>> {
310        &mut self.x
311    }
312
313    /// Current covariance matrix `P ∈ ℝⁿˣⁿ`.
314    #[inline]
315    pub fn covariance(&self) -> &OMatrix<f32, Const<N>, Const<N>> {
316        &self.p
317    }
318
319    /// Forgetting factor `λ`.
320    #[inline]
321    pub fn lambda(&self) -> f32 {
322        self.lambda
323    }
324
325    /// Set the forgetting factor `λ ∈ (0, 1]`.
326    #[inline]
327    pub fn set_lambda(&mut self, lambda: f32) {
328        self.lambda = lambda;
329    }
330
331    /// Number of samples processed so far.
332    #[inline]
333    pub fn samples(&self) -> u32 {
334        self.samples
335    }
336}
337
338// ═══════════════════════════════════════════════════════════════════════════
339//  RlsParallel<N, P> — parallel multi-output with shared covariance
340// ═══════════════════════════════════════════════════════════════════════════
341
342/// Parallel multi-output RLS with shared covariance.
343///
344/// Runs `P` independent scalar-output RLS instances that share the same
345/// regressor vector `a ∈ ℝⁿ` and covariance matrix `P ∈ ℝⁿˣⁿ`. Since
346/// each observation is scalar, the denominator `λ + a P aᵀ` is a scalar—no
347/// Cholesky decomposition required.
348///
349/// # Model
350///
351/// ```text
352/// yᵀ = a X    where  yᵀ ∈ ℝᵖ,  X ∈ ℝⁿˣᵖ,  a ∈ ℝⁿ
353/// ```
354///
355/// Each column `xⱼ` of `X` is an independent parameter vector. All `P`
356/// outputs share the covariance update but have independent parameter
357/// updates weighted by their individual prediction errors.
358///
359/// # Type parameters
360///
361/// - `N` — number of parameters per output (regressor dimension)
362/// - `P` — number of parallel outputs
363///
364/// # Example
365///
366/// ```no_run
367/// use flight_solver::rls::{RlsParallel, CovarianceGuards};
368///
369/// // 4 parameters, 3 parallel outputs (e.g. G1 learning for roll/pitch/yaw)
370/// let mut rls = RlsParallel::<4, 3>::new(1e2, 0.995, CovarianceGuards::default());
371///
372/// let a = nalgebra::SVector::<f32, 4>::new(0.1, -0.2, 0.3, 0.05);
373/// let y = nalgebra::SVector::<f32, 3>::new(0.5, -0.3, 0.1);
374///
375/// let stats = rls.update(&a, &y);
376/// ```
377#[derive(Clone)]
378pub struct RlsParallel<const N: usize, const P: usize> {
379    /// Parameter matrix `X ∈ ℝⁿˣᵖ` (column `j` = parameters for output `j`).
380    x: OMatrix<f32, Const<N>, Const<P>>,
381    /// Shared covariance matrix `P ∈ ℝⁿˣⁿ` (symmetric positive definite).
382    p: OMatrix<f32, Const<N>, Const<N>>,
383    /// Forgetting factor `λ ∈ (0, 1]`.
384    lambda: f32,
385    /// Number of samples processed.
386    samples: u32,
387    /// Numerical guard configuration.
388    guards: CovarianceGuards,
389}
390
391impl<const N: usize, const P: usize> RlsParallel<N, P>
392where
393    Const<N>: DimName,
394    Const<P>: DimName,
395{
396    /// Create a new parallel RLS with initial covariance `P = γ I`.
397    ///
398    /// # Arguments
399    ///
400    /// - `gamma` — initial covariance diagonal
401    /// - `lambda` — forgetting factor in `(0, 1]`
402    /// - `guards` — numerical guard configuration
403    pub fn new(gamma: f32, lambda: f32, guards: CovarianceGuards) -> Self {
404        let mut p = OMatrix::<f32, Const<N>, Const<N>>::zeros();
405        for i in 0..N {
406            p[(i, i)] = gamma;
407        }
408        Self {
409            x: OMatrix::<f32, Const<N>, Const<P>>::zeros(),
410            p,
411            lambda,
412            samples: 0,
413            guards,
414        }
415    }
416
417    /// Create from a time-constant–based forgetting factor, matching
418    /// indiflight's `rlsParallelInit`.
419    ///
420    /// ```text
421    /// λ = (1 − ln 2)^(Ts / Tchar)
422    /// ```
423    ///
424    /// At time `Tchar`, the weight of the oldest sample has decayed to 50%.
425    ///
426    /// # Arguments
427    ///
428    /// - `gamma` — initial covariance diagonal
429    /// - `ts` — sampling period in seconds (e.g. `1.0 / 8000.0`)
430    /// - `t_char` — characteristic forgetting time in seconds
431    /// - `guards` — numerical guard configuration
432    pub fn from_time_constant(gamma: f32, ts: f32, t_char: f32, guards: CovarianceGuards) -> Self {
433        let lambda = libm::powf(1.0 - core::f32::consts::LN_2, ts / t_char);
434        Self::new(gamma, lambda, guards)
435    }
436
437    /// Process one observation and update parameter estimates.
438    ///
439    /// # Arguments
440    ///
441    /// - `a` — shared regressor vector `a ∈ ℝⁿ`
442    /// - `y` — observation vector `y ∈ ℝᵖ` (one scalar per output)
443    ///
444    /// # Algorithm
445    ///
446    /// 1. Covariance explosion check
447    /// 2. Compute `P aᵀ` and scalar `a P aᵀ`
448    /// 3. Gain vector: `k = P aᵀ / (λ + a P aᵀ)`
449    /// 4. Per-diagonal order decrement limiting
450    /// 5. Covariance update: `P ← (KAPmult · P − k (P aᵀ)ᵀ) / λ`
451    /// 6. Parameter update: `X ← X + k eᵀ`
452    #[inline]
453    pub fn update(
454        &mut self,
455        a: &OVector<f32, Const<N>>,
456        y: &OVector<f32, Const<P>>,
457    ) -> UpdateStats {
458        self.samples += 1;
459
460        // ── Step 1: Covariance explosion detection ──────────────────────
461        let mut lam = self.lambda;
462        let mut diag_p = [0.0f32; N];
463        let mut exit_code = ExitCode::Success;
464        for i in 0..N {
465            diag_p[i] = self.p[(i, i)];
466            if diag_p[i] > self.guards.cov_max {
467                lam = 1.0 + 0.1 * (1.0 - self.lambda);
468                exit_code = ExitCode::CovarianceExplosion;
469            }
470        }
471
472        // ── Step 2: Compute P aᵀ (n-vector) ────────────────────────────
473        // Matching indiflight: SGEMVt(n, n, P, aT, PaT)
474        // PaT[i] = Σ_k P^T[i,k] * a[k] = Σ_k P[k,i] * a[k]
475        let mut pa = [0.0f32; N];
476        for i in 0..N {
477            let mut sum = 0.0f32;
478            for k in 0..N {
479                sum += self.p[(k, i)] * a[k];
480            }
481            pa[i] = sum;
482        }
483
484        // ── Step 3: Scalar denominator a P aᵀ ──────────────────────────
485        let mut a_pa: f32 = 0.0;
486        for i in 0..N {
487            a_pa += a[i] * pa[i];
488        }
489
490        // ── Step 4: Prediction errors (computed before param update) ────
491        // eT[j] = y[j] - aᵀ X[:,j]
492        let mut e = [0.0f32; P];
493        for j in 0..P {
494            let mut ax = 0.0f32;
495            for k in 0..N {
496                ax += a[k] * self.x[(k, j)];
497            }
498            e[j] = y[j] - ax;
499        }
500
501        // ── Step 5: Gain vector k = P aᵀ / (λ + a P aᵀ) ───────────────
502        let i_sig = 1.0 / (lam + a_pa);
503        let mut k = [0.0f32; N];
504        for i in 0..N {
505            k[i] = pa[i] * i_sig;
506        }
507
508        // ── Step 6: Per-diagonal order decrement limiting ───────────────
509        // diagKAP[i] = k[i] * PaT[i] (diagonal of rank-1 matrix k ⊗ PaT)
510        let mut max_diag_ratio: f32 = 0.0;
511        for i in 0..N {
512            let diag_kap = k[i] * pa[i];
513            if diag_kap > 1e-6 {
514                let ratio = diag_p[i] / diag_kap;
515                if ratio > max_diag_ratio {
516                    max_diag_ratio = ratio;
517                }
518            }
519        }
520
521        let mut kap_mult: f32 = 1.0;
522        if max_diag_ratio > self.guards.cov_min {
523            kap_mult = ((1.0 - self.guards.max_order_decrement) * max_diag_ratio).min(1.0);
524        }
525
526        // ── Step 7: Covariance update ───────────────────────────────────
527        // P = (KAPmult · P − k ⊗ PaT) / λ
528        // Matching indiflight: SGEMM(n, n, 1, k, PaT, P, -KAPmult, -ilam)
529        // Fused form: P[r,c] = (kap_mult/λ)*P[r,c] - (1/λ)*k[r]*pa[c]
530        // Saves one multiply per inner iteration vs the two-step version.
531        let kap_ilam = kap_mult / lam;
532        let neg_ilam = -1.0 / lam;
533        for col in 0..N {
534            let pa_col_scaled = pa[col] * neg_ilam;
535            for row in 0..N {
536                self.p[(row, col)] = kap_ilam * self.p[(row, col)] + k[row] * pa_col_scaled;
537            }
538        }
539
540        // ── Step 8: Parameter update X += k eᵀ ─────────────────────────
541        // Matching indiflight: SGEMM(n, p, 1, k, eT, X, 1, 1)
542        for j in 0..P {
543            for i in 0..N {
544                self.x[(i, j)] += k[i] * e[j];
545            }
546        }
547
548        UpdateStats {
549            exit_code,
550            samples: self.samples,
551        }
552    }
553
554    /// Current parameter matrix `X ∈ ℝⁿˣᵖ`.
555    #[inline]
556    pub fn params(&self) -> &OMatrix<f32, Const<N>, Const<P>> {
557        &self.x
558    }
559
560    /// Mutable access to the parameter matrix.
561    #[inline]
562    pub fn params_mut(&mut self) -> &mut OMatrix<f32, Const<N>, Const<P>> {
563        &mut self.x
564    }
565
566    /// Current shared covariance matrix `P ∈ ℝⁿˣⁿ`.
567    #[inline]
568    pub fn covariance(&self) -> &OMatrix<f32, Const<N>, Const<N>> {
569        &self.p
570    }
571
572    /// Forgetting factor `λ`.
573    #[inline]
574    pub fn lambda(&self) -> f32 {
575        self.lambda
576    }
577
578    /// Set the forgetting factor `λ ∈ (0, 1]`.
579    #[inline]
580    pub fn set_lambda(&mut self, lambda: f32) {
581        self.lambda = lambda;
582    }
583
584    /// Number of samples processed so far.
585    #[inline]
586    pub fn samples(&self) -> u32 {
587        self.samples
588    }
589}