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}