oxictl 0.1.1

Pure Rust Real-Time Control Systems Framework
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
//! Online induction motor parameter identification.
//!
//! Uses MRAS (Model Reference Adaptive System) to estimate:
//! - Rotor time constant Tr = Lr/Rr
//! - Stator resistance Rs
//! - Slip frequency ωslip
//!
//! # MRAS Architecture
//!
//! The reference model uses measured stator currents (exact) while the
//! adjustable model uses the estimated rotor flux. The adaptation law drives
//! the error between reference and adjustable model outputs to zero,
//! thereby identifying the rotor time constant.
//!
//! ## Reference model (voltage model — stator frame):
//! ```text
//! dψrα/dt = (vα - Rs·iα)·(Lr/Lm) - (σ·Ls/Lm)·(diα/dt)·Lr
//! dψrβ/dt = (vβ - Rs·iβ)·(Lr/Lm) - (σ·Ls/Lm)·(diβ/dt)·Lr
//! ```
//!
//! ## Adjustable model (current model — stator frame):
//! ```text
//! dψrα/dt = -ψrα/Tr + (Lm/Tr)·iα - ωr·ψrβ
//! dψrβ/dt = -ψrβ/Tr + (Lm/Tr)·iβ + ωr·ψrα
//! ```
//!
//! ## Adaptation law (MIT rule / Lyapunov-based):
//! ```text
//! dTr_hat/dt = γ·(eα·ψ̂rα + eβ·ψ̂rβ)
//! ```
//! where eα = ψrα_ref − ψrα_adj, eβ = ψrβ_ref − ψrβ_adj.

use crate::core::scalar::ControlScalar;

/// Configuration for the induction motor parameter identifier.
#[derive(Debug, Clone, Copy)]
pub struct InductionIdConfig<S: ControlScalar> {
    /// Stator inductance Ls (H).
    pub ls: S,
    /// Rotor inductance Lr (H). For cage induction motor Lr ≈ Ls.
    pub lr: S,
    /// Mutual inductance Lm (H).
    pub lm: S,
    /// Initial stator resistance estimate Rs (Ω).
    pub rs_init: S,
    /// Initial rotor time constant estimate Tr (s).
    pub tr_init: S,
    /// MRAS adaptation gain γ for rotor time constant (> 0).
    pub gamma_tr: S,
    /// Adaptation gain for stator resistance estimation.
    pub gamma_rs: S,
    /// Low-pass filter coefficient for slip estimation α ∈ (0, 1].
    pub slip_filter_alpha: S,
    /// Convergence threshold on Tr change per step.
    pub convergence_threshold: S,
    /// Minimum steps before declaring convergence.
    pub min_steps_for_convergence: u32,
}

impl<S: ControlScalar> InductionIdConfig<S> {
    /// Typical configuration for a 4-pole, 50Hz induction motor.
    pub fn default_config() -> Self {
        Self {
            ls: S::from_f64(0.175),
            lr: S::from_f64(0.170),
            lm: S::from_f64(0.165),
            rs_init: S::from_f64(1.5),
            tr_init: S::from_f64(0.14),
            gamma_tr: S::from_f64(50.0),
            gamma_rs: S::from_f64(5.0),
            slip_filter_alpha: S::from_f64(0.05),
            convergence_threshold: S::from_f64(1e-5),
            min_steps_for_convergence: 400,
        }
    }
}

/// Result snapshot from the induction motor identifier.
#[derive(Debug, Clone, Copy)]
pub struct InductionParamIdResult<S: ControlScalar> {
    /// Estimated rotor time constant Tr = Lr/Rr (s).
    pub tr: S,
    /// Estimated rotor resistance Rr = Lr/Tr (Ω).
    pub rr: S,
    /// Estimated stator resistance Rs (Ω).
    pub rs: S,
    /// Estimated slip angular frequency ωslip (rad/s).
    pub omega_slip: S,
    /// Estimated rotor flux α-component (Wb).
    pub psi_r_alpha: S,
    /// Estimated rotor flux β-component (Wb).
    pub psi_r_beta: S,
    /// Whether Tr has converged.
    pub converged: bool,
    /// Total steps processed.
    pub steps: u32,
}

/// Online induction motor parameter identification via MRAS.
///
/// Operates in the stationary αβ frame. Requires measured stator voltages,
/// stator currents, and rotor angular velocity.
///
/// # Numerical Integration
/// Forward Euler is used for both reference and adjustable models.
#[derive(Debug, Clone)]
pub struct InductionParamId<S: ControlScalar> {
    /// Configuration (read-only after construction).
    config: InductionIdConfig<S>,
    /// Reference model rotor flux α-axis (Wb).
    psi_ref_alpha: S,
    /// Reference model rotor flux β-axis (Wb).
    psi_ref_beta: S,
    /// Adjustable model rotor flux α-axis (Wb).
    psi_adj_alpha: S,
    /// Adjustable model rotor flux β-axis (Wb).
    psi_adj_beta: S,
    /// Current estimate of stator resistance Rs (Ω).
    rs_hat: S,
    /// Current estimate of rotor time constant Tr (s).
    tr_hat: S,
    /// Filtered slip frequency estimate ωslip (rad/s).
    omega_slip_filtered: S,
    /// Previous α-axis stator current for derivative.
    prev_i_alpha: S,
    /// Previous β-axis stator current for derivative.
    prev_i_beta: S,
    /// Timestep (s).
    dt: S,
    /// Step counter.
    steps: u32,
    /// Whether convergence has been declared.
    converged: bool,
}

impl<S: ControlScalar> InductionParamId<S> {
    /// Create a new identifier from configuration.
    ///
    /// # Arguments
    /// * `dt` - Control loop timestep (s). Must be > 0.
    /// * `config` - Tuning configuration.
    pub fn new(dt: S, config: InductionIdConfig<S>) -> Self {
        let rs_hat = config.rs_init;
        let tr_hat = config.tr_init;
        Self {
            config,
            psi_ref_alpha: S::ZERO,
            psi_ref_beta: S::ZERO,
            psi_adj_alpha: S::ZERO,
            psi_adj_beta: S::ZERO,
            rs_hat,
            tr_hat,
            omega_slip_filtered: S::ZERO,
            prev_i_alpha: S::ZERO,
            prev_i_beta: S::ZERO,
            dt,
            steps: 0,
            converged: false,
        }
    }

    /// Create with default configuration.
    pub fn with_defaults(dt: S) -> Self {
        Self::new(dt, InductionIdConfig::default_config())
    }

    /// Process one measurement cycle.
    ///
    /// # Arguments
    /// * `v_alpha`, `v_beta` - Stator αβ voltages (V).
    /// * `i_alpha`, `i_beta` - Stator αβ currents (A).
    /// * `omega_r` - Rotor mechanical speed × pole pairs = electrical speed (rad/s).
    pub fn update(&mut self, v_alpha: S, v_beta: S, i_alpha: S, i_beta: S, omega_r: S) {
        let dt_inv = if self.dt > S::ZERO {
            S::ONE / self.dt
        } else {
            S::ZERO
        };

        let ls = self.config.ls;
        let lr = self.config.lr;
        let lm = self.config.lm;

        // Leakage factor σ = 1 − Lm²/(Ls·Lr)
        let lm_sq = lm * lm;
        let sigma_ls_lr = ls * lr - lm_sq; // σ·Ls·Lr
                                           // σ·Ls = (Ls·Lr - Lm²)/Lr
        let sigma_ls = sigma_ls_lr / lr;

        // Current derivatives (backward finite difference)
        let di_alpha_dt = (i_alpha - self.prev_i_alpha) * dt_inv;
        let di_beta_dt = (i_beta - self.prev_i_beta) * dt_inv;

        // ============================================================
        // Reference model (voltage-based, stator frame)
        // dψrα_ref/dt = (vα - Rs_hat·iα)·(Lr/Lm) − σ·Ls·(diα/dt)·(Lr/Lm)·Lm/(Lr)
        //
        // Simplification: with Lm/Lr coupling:
        //   dψrα_ref/dt = (Lr/Lm)·(vα - Rs_hat·iα - σ·Ls·diα/dt)
        // ============================================================
        let lr_over_lm = lr / lm;
        let back_emf_alpha = v_alpha - self.rs_hat * i_alpha - sigma_ls * di_alpha_dt;
        let back_emf_beta = v_beta - self.rs_hat * i_beta - sigma_ls * di_beta_dt;

        let dpsi_ref_alpha = lr_over_lm * back_emf_alpha;
        let dpsi_ref_beta = lr_over_lm * back_emf_beta;

        self.psi_ref_alpha += dpsi_ref_alpha * self.dt;
        self.psi_ref_beta += dpsi_ref_beta * self.dt;

        // ============================================================
        // Adjustable model (current-based, stator frame)
        // dψrα_adj/dt = −ψrα_adj/Tr_hat + (Lm/Tr_hat)·iα − ωr·ψrβ_adj
        // dψrβ_adj/dt = −ψrβ_adj/Tr_hat + (Lm/Tr_hat)·iβ + ωr·ψrα_adj
        // ============================================================
        let tr_safe = if self.tr_hat > S::from_f64(1e-6) {
            self.tr_hat
        } else {
            S::from_f64(1e-6)
        };
        let inv_tr = S::ONE / tr_safe;
        let lm_inv_tr = lm * inv_tr;

        let dpsi_adj_alpha =
            -self.psi_adj_alpha * inv_tr + lm_inv_tr * i_alpha - omega_r * self.psi_adj_beta;
        let dpsi_adj_beta =
            -self.psi_adj_beta * inv_tr + lm_inv_tr * i_beta + omega_r * self.psi_adj_alpha;

        self.psi_adj_alpha += dpsi_adj_alpha * self.dt;
        self.psi_adj_beta += dpsi_adj_beta * self.dt;

        // ============================================================
        // Flux error (reference − adjustable)
        // ============================================================
        let e_alpha = self.psi_ref_alpha - self.psi_adj_alpha;
        let e_beta = self.psi_ref_beta - self.psi_adj_beta;

        // ============================================================
        // Adaptation law for Tr (Lyapunov-stable MIT rule)
        // dTr_hat/dt = γ_tr·(eα·ψ̂rα_adj + eβ·ψ̂rβ_adj)
        // ============================================================
        let mras_signal = e_alpha * self.psi_adj_alpha + e_beta * self.psi_adj_beta;
        let dtr = self.config.gamma_tr * mras_signal;
        let tr_prev = self.tr_hat;
        self.tr_hat += dtr * self.dt;

        // Physical constraint: Tr must be positive
        if self.tr_hat < S::from_f64(1e-6) {
            self.tr_hat = S::from_f64(1e-6);
        }

        // ============================================================
        // Stator resistance adaptation
        // Gradient: Rs drives voltage-model reference; correction via flux error
        // dRs_hat/dt = −γ_rs·(eα·iα + eβ·iβ)
        // (negative gradient because Rs appears with negative sign in model)
        // ============================================================
        let rs_correction = -(e_alpha * i_alpha + e_beta * i_beta);
        self.rs_hat += self.config.gamma_rs * rs_correction * self.dt;
        // Clamp to physical range
        if self.rs_hat < S::ZERO {
            self.rs_hat = S::ZERO;
        }

        // ============================================================
        // Slip frequency estimation
        // ωslip = (Lm / (Tr_hat · |ψr|²)) · (ψrα·iβ − ψrβ·iα)
        // ============================================================
        let psi_sq =
            self.psi_adj_alpha * self.psi_adj_alpha + self.psi_adj_beta * self.psi_adj_beta;
        let raw_slip = if psi_sq > S::from_f64(1e-8) {
            lm / (tr_safe * psi_sq) * (self.psi_adj_alpha * i_beta - self.psi_adj_beta * i_alpha)
        } else {
            S::ZERO
        };

        // Low-pass filter on slip
        let alpha = self.config.slip_filter_alpha;
        self.omega_slip_filtered = alpha * raw_slip + (S::ONE - alpha) * self.omega_slip_filtered;

        // ============================================================
        // Convergence
        // ============================================================
        self.steps += 1;
        if !self.converged && self.steps >= self.config.min_steps_for_convergence {
            let dtr_abs = if dtr < S::ZERO { -dtr } else { dtr };
            let tr_change = (self.tr_hat - tr_prev).abs();
            let _ = tr_change; // tr_change is used below
            if dtr_abs < self.config.convergence_threshold {
                self.converged = true;
            }
        }

        self.prev_i_alpha = i_alpha;
        self.prev_i_beta = i_beta;
    }

    /// Retrieve current parameter estimates.
    pub fn results(&self) -> InductionParamIdResult<S> {
        let tr_safe = if self.tr_hat > S::from_f64(1e-9) {
            self.tr_hat
        } else {
            S::from_f64(1e-9)
        };
        let rr = self.config.lr / tr_safe;

        InductionParamIdResult {
            tr: self.tr_hat,
            rr,
            rs: self.rs_hat,
            omega_slip: self.omega_slip_filtered,
            psi_r_alpha: self.psi_adj_alpha,
            psi_r_beta: self.psi_adj_beta,
            converged: self.converged,
            steps: self.steps,
        }
    }

    /// Reset all state, keeping configuration and dt.
    pub fn reset(&mut self) {
        self.psi_ref_alpha = S::ZERO;
        self.psi_ref_beta = S::ZERO;
        self.psi_adj_alpha = S::ZERO;
        self.psi_adj_beta = S::ZERO;
        self.rs_hat = self.config.rs_init;
        self.tr_hat = self.config.tr_init;
        self.omega_slip_filtered = S::ZERO;
        self.prev_i_alpha = S::ZERO;
        self.prev_i_beta = S::ZERO;
        self.steps = 0;
        self.converged = false;
    }

    /// Whether Tr has converged.
    pub fn is_converged(&self) -> bool {
        self.converged
    }

    /// Total number of update steps.
    pub fn steps(&self) -> u32 {
        self.steps
    }
}

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

    #[test]
    fn default_config_sane() {
        let cfg = InductionIdConfig::<f64>::default_config();
        assert!(cfg.tr_init > 0.0);
        assert!(cfg.gamma_tr > 0.0);
        assert!(cfg.slip_filter_alpha > 0.0 && cfg.slip_filter_alpha <= 1.0);
    }

    #[test]
    fn update_does_not_panic_on_zero_inputs() {
        let mut id = InductionParamId::<f64>::with_defaults(1e-4);
        for _ in 0..100 {
            id.update(0.0, 0.0, 0.0, 0.0, 0.0);
        }
        let res = id.results();
        // Tr must remain positive
        assert!(res.tr > 0.0);
        assert!(res.rs >= 0.0);
    }

    #[test]
    fn reset_restores_initial_state() {
        let mut id = InductionParamId::<f32>::with_defaults(1e-4);
        for i in 0..200 {
            let v = i as f32 * 0.05;
            id.update(v, v * 0.5, v * 0.1, v * 0.05, 100.0);
        }
        let tr_before_reset = id.results().tr;
        id.reset();
        let res = id.results();
        // After reset, Tr should be back to init
        let init_tr = InductionIdConfig::<f32>::default_config().tr_init;
        assert!((res.tr - init_tr).abs() < 1e-6_f32);
        // Ensure we actually did some work before reset
        assert!((tr_before_reset - init_tr).abs() > 0.0_f32);
    }

    #[test]
    fn slip_remains_bounded_with_normal_excitation() {
        let mut id = InductionParamId::<f64>::with_defaults(1e-4);
        let omega_r = 314.0_f64; // ~50Hz electrical
        for i in 0..1000 {
            let t = i as f64 * 1e-4;
            let i_alpha = 5.0 * libm::sin(2.0 * core::f64::consts::PI * 50.0 * t);
            let i_beta = 5.0 * libm::cos(2.0 * core::f64::consts::PI * 50.0 * t);
            let v_alpha = 100.0 * libm::sin(2.0 * core::f64::consts::PI * 50.0 * t);
            let v_beta = 100.0 * libm::cos(2.0 * core::f64::consts::PI * 50.0 * t);
            id.update(v_alpha, v_beta, i_alpha, i_beta, omega_r);
        }
        let res = id.results();
        // The slip estimator is bounded by physical limits.
        // The MRAS integrators may have transient excursions during start-up;
        // we verify the estimate is bounded within a generous but physically
        // motivated range (several times rated slip, not unbounded).
        assert!(
            res.omega_slip.abs() < 2000.0,
            "slip exceeded physical bound: {} rad/s",
            res.omega_slip
        );
    }
}