Stator Inductance (Ls) Calculation

Purpose

This document describes, in implementation detail, how the Motor Health Check firmware state machine (DESC_081, Software Architecture) measures the apparent line-to-line stator inductance during the LDQ_TEST state (HC_STATE_LDQ_TEST). The implementation lives in Src/health_check_sm.c (helpers ls_get_phase_current, ls_measure_phase and ls_measure_phase_avg, state handler hc_state_ldq_test), with the result structure HC_LsTest_t declared in Inc/health_check_sm.h.

The Ls test runs immediately after RS_TEST and before BUILD_TEMPORARY_MOTOR_MODEL in the nominal health-check sequence:

... → PASSIVE_MOTOR_CHECK → RS_TEST → LDQ_TEST → BUILD_TEMPORARY_MOTOR_MODEL → ...

It depends directly on the results of RS_TEST (see Stator Resistance (Rs) Calculation) in three ways: the loop resistance computed there (corrected back to the uncorrected total, see The switch-resistance correction, in reverse) is used as the R in this test’s L = τ·R calculation, the open/closed flags determine which phases are measured at all, and the injection duty cycle is inherited from the highest duty the RS test’s ramp reached.

Measurement principle — RL step-response curve fit

When a constant voltage step V is applied to a series RL circuit that starts at zero current, the current rises exponentially toward its steady-state value:

\[i(t) = I_{ss} \left(1 - e^{-t/\tau}\right), \qquad \tau = \frac{L}{R}, \qquad I_{ss} = \frac{V}{R}\]

The firmware re-uses exactly the same injection circuit as the RS test (drive one phase, low-side return through the other two — rs_apply_injection()), but instead of waiting for steady state and averaging, it captures the rising transient at high time-resolution and fits it to the model above to extract the time constant τ. Knowing the loop resistance R, the loop inductance follows directly as L = τ·R — and that loop value, expressed in line-to-line terms (Luv, Lvw, Lwu), is exactly what the test reports (see Reporting — line-to-line, not per-phase for why no further conversion is attempted).

This curve-fitting approach is used — rather than, say, measuring ripple amplitude at steady state — because it does not depend on knowing the exact applied voltage (which is distorted at low duty cycles by inverter dead time); it only needs the shape of the current rise and the loop resistance.

The switch-resistance correction, in reverse

The RS test reports hc->rs.r_u_ohm / r_v_ohm / r_w_ohm as the apparent winding (line-to-line) resistance — i.e. with the fixed high-side + low-side MOSFET Rds(on) contribution (RS_SWITCH_RESISTANCE_OHM) already subtracted out, so that the value presented to the user reflects the motor only (see Stator Resistance (Rs) Calculation, section Apparent-resistance formula and the switch-resistance correction).

The Ls test, however, excites the same physical circuit that the RS test measured before that correction was applied — the RL transient seen at the current sensor is shaped by the total loop resistance, switches included. Using the corrected (motor-only) resistance as R in τ = L / R would therefore yield a biased L. The firmware accounts for this by adding the switch-resistance contribution back before calling ls_measure_phase():

/* hc->rs.r_u_ohm is the apparent winding resistance with the switch
   contribution already subtracted out — but the physical RL transient
   excited here is still loaded by those switches, so the time constant
   needs the UNCORRECTED total resistance. */
float r_total = hc->rs.r_u_ohm + RS_SWITCH_RESISTANCE_OHM;
hc->ls.l_u_H  = ls_measure_phase_avg(0U, r_total, hc->rs.max_duty_pct, "U");

(and likewise for V/W with r_v_ohm / r_w_ohm and drive phases 1/2). RS_SWITCH_RESISTANCE_OHM is the single #define shared by both tests — see Stator Resistance (Rs) Calculation for its value and rationale.

Injection duty — inherited from the Rs ramp

Earlier revisions injected the Ls step at a separately-configurable duty cycle (g_rs_duty_pct / RS:DUTY:<pct>). The Ls test now instead reuses hc->rs.max_duty_pctthe highest duty cycle any phase’s ramp reached during the RS test (tracked in rs_ramp_measure_step(), reset to 0 in HC_SM_Init()):

if (hc->rs.duty_pct > hc->rs.max_duty_pct)
{
    hc->rs.max_duty_pct = hc->rs.duty_pct;
}

This keeps the Ls measurement at the same operating point that characterized Rs (same injected current magnitude, same switch conduction state, same thermal conditions), rather than introducing a second, independently-tunable duty that could drift away from the conditions Rs was measured under. There is consequently no “LS injection duty” control in the GUI — it is fully derived from the RS_TEST outcome.

Sub-state machine

LDQ_TEST is internally a sequential sub-state machine, tracked in hc->ls.sub (HC_LsTest_t.sub). Unlike the RS test (which spreads its settle/measure phases across many scheduler ticks), each Ls sub-state performs its entire measurement — including all repetitions, see below — synchronously inside a single call to hc_state_ldq_test().

ID

Sub-state

Action

0

LS_SUB_BASELINE

Calls PhaseCurrent_Calibrate() to re-capture the zero-current ADC offset (PWM is already off, left that way by RS_SUB_DONE). Logs [LS] Calibrating current baseline... / [LS] Baseline captured.

1

LS_SUB_SETUP_U

If hc->rs.open_u is set (phase reported open by the RS test), logs [LS] U: skipped (open circuit on Rs test) and stores hc->ls.l_u_H = 0. Otherwise logs [LS] Measuring U..., adds back the switch resistance, and calls ls_measure_phase_avg(0, hc->rs.r_u_ohm + RS_SWITCH_RESISTANCE_OHM, hc->rs.max_duty_pct, "U") — which runs g_ls_repeat_count repetitions and returns their average — storing the result in hc->ls.l_u_H and logging [LS] U: avg <uH> uH over <n> rep(s). Either way, advances to LS_SUB_SETUP_V.

2

LS_SUB_SETUP_V

Same sequence driving phase V (hc->rs.open_v / hc->rs.r_v_ohm / drive phase 1) → hc->ls.l_v_H. Advances to LS_SUB_SETUP_W.

3

LS_SUB_SETUP_W

Same sequence driving phase W (hc->rs.open_w / hc->rs.r_w_ohm / drive phase 2) → hc->ls.l_w_H. Advances to LS_SUB_DONE.

4

LS_SUB_DONE

Computes the imbalance flag (against the configurable g_ls_imbalance_thresh), logs the LS: summary line and the pass/fail verdict, then transitions the top-level state machine to HC_STATE_BUILD_TEMPORARY_MOTOR_MODEL.

Repeated measurement and averaging

As with the RS test (see Stator Resistance (Rs) Calculation), each phase’s inductance measurement can be repeated multiple times and averaged for repeatability. This is controlled by the global g_ls_repeat_count (UART command LS:REPEAT:<count>, range 1–25, default LS_REPEAT_DEFAULT = 1U) and implemented by a small shared helper, ls_measure_phase_avg():

static float ls_measure_phase_avg(uint8_t drive_phase, float r_eff_ohm,
                                   uint8_t duty_pct, const char *tag)
{
    float   acc   = 0.0f;
    uint8_t valid = 0U;

    for (uint8_t rep = 0U; rep < g_ls_repeat_count; rep++)
    {
        float l = ls_measure_phase(drive_phase, r_eff_ohm, duty_pct);
        /* SimpleUART_Log: "[LS] <tag> rep <i>/<n>: <uH>uH[ (capture failed)]" */
        if (l > 0.0f) { acc += l; valid++; }
    }
    return (valid > 0U) ? (acc / (float)valid) : 0.0f;
}

Each repetition is logged individually (e.g. [LS] U rep 3/10: 182uH, or ... (capture failed) when ls_measure_phase() returns 0 for that attempt — see Returns 0 on failure). Failed captures are excluded from the average rather than counted as zero, so a handful of noisy captures don’t pull the reported value down; if every repetition fails the average is itself 0 (propagating the “no usable measurement” result in the same way a single failed measurement always has). The calling sub-state then logs a per-phase summary, e.g. [LS] U: avg 178uH over 10 reps.

With the default g_ls_repeat_count = 1, the behavior is identical to a single capture per phase, as in earlier revisions.

ls_measure_phase() step by step

The function signature is:

static float ls_measure_phase(uint8_t drive_phase, float r_eff_ohm, uint8_t duty_pct)

where drive_phase selects U/V/W (0/1/2, same convention as rs_apply_injection), r_eff_ohm must be the total uncorrected loop resistance for that phase’s injection path — i.e. hc->rs.r_x_ohm + RS_SWITCH_RESISTANCE_OHM (see The switch-resistance correction, in reverse) — and duty_pct is the injection duty to apply (callers pass hc->rs.max_duty_pct, see Injection duty — inherited from the Rs ramp). The procedure is:

  1. Guard against an unusable resistance. If r_eff_ohm < 0.001 Ω, return 0.0f immediately — no measurement is attempted. (In practice this is now mostly a defensive check: open phases are filtered out earlier, in hc_state_ldq_test(), by the RS test’s open_x flags, and r_eff_ohm always includes the non-zero switch-resistance term for any phase that is measured.)

  2. Enable the DWT cycle counter for microsecond-resolution timestamps that are independent of the PWM/ADC update rate:

    CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;
    DWT->CTRL        |= DWT_CTRL_CYCCNTENA_Msk;
    

    This avoids the bias that affected an earlier implementation, where sample timing was inferred from the PWM period and each phase picked up a different “PWM-trough phase offset”.

  3. Apply the step injection with rs_apply_injection(drive_phase, duty_pct) (same helper as the RS test — drive phase at duty_pct duty, the other two phases’ low sides on as the return path) and record the injection-start timestamp t0 = DWT->CYCCNT.

  4. Capture the rising transient. Loop LS_NUM_SAMPLES (24) times:

    • Busy-wait for the MCSDK ISR to post a new ADC sample, by polling ls_get_phase_current(drive_phase) until its value changes from the previous reading (bounded by a 200 000-iteration timeout per sample; on timeout the capture loop is aborted early with whatever samples it has so far).

    • Compute the elapsed time since injection start directly from the cycle counter: t = (DWT->CYCCNT - t0) / SystemCoreClock seconds — a precise, drift-free measure that does not assume anything about the PWM rate.

    • Store the rectified current magnitude |i| (the injected current can read negative depending on which shunt/phase is driven) and the timestamp into parallel buffers i_buf / t_buf.

    ls_get_phase_current() simply maps drive_phase (0/1/2) onto PhaseCurrent_Read().ia_A / ib_A / ic_A.

  5. Switch the PWM off (PWMC_SwitchOffPWM) as soon as the capture loop ends — the injection is no longer needed once the transient has been recorded.

  6. Sanity-check the capture. If fewer than LS_SS_TAIL + 2 (8) valid samples were collected, return 0.0f (capture failed/timed out).

  7. Estimate the steady-state current ``I_ss`` by averaging the last LS_SS_TAIL (6) samples — the “settled tail” of the waveform:

    float i_ss = 0.0f;
    for (k = ncoll - LS_SS_TAIL; k < ncoll; k++) { i_ss += i_buf[k]; }
    i_ss /= (float)LS_SS_TAIL;
    if (i_ss < 0.01f) { return 0.0f; }
    

    Estimating I_ss from the measured waveform itself — rather than computing it as V_bus·duty / R — sidesteps the inverter dead-time distortion that makes the commanded voltage an unreliable predictor of the actual applied voltage at low duty cycles. A result below 10 mA is treated as an invalid/open-circuit measurement.

  8. Linearize and fit. Rearranging the step-response equation:

    \[\ln\!\left(1 - \frac{i(t)}{I_{ss}}\right) = -\frac{t}{\tau}\]

    gives a straight line through the origin with slope -1/τ. The code performs an origin-forced linear least-squares fit — i.e. it assumes the line passes through (0, 0) (true by construction: i(0) = 0) and solves only for the slope:

    \[\min_{\tau} \sum_n \left(y_n + \frac{t_n}{\tau}\right)^2 \;\;\Longrightarrow\;\; \frac{1}{\tau} = \frac{-\sum_n t_n y_n}{\sum_n t_n^2} \;\;\Longrightarrow\;\; \tau = \frac{-\sum_n t_n^2}{\sum_n t_n y_n}\]

    where y_n = ln(1 - i_n/I_ss) (always negative for a rising current below I_ss). In code:

    for (k = 0; k < ncoll - LS_SS_TAIL; k++)
    {
        float ratio = i_buf[k] / i_ss;
        if (ratio < 0.10f || ratio > 0.90f) { continue; }   /* clean region only */
        float y  = logf(1.0f - ratio);
        sum_ty  += t_buf[k] * y;
        sum_tt  += t_buf[k] * t_buf[k];
    }
    tau = -sum_tt / sum_ty;
    

    Only samples whose ratio = i/I_ss falls strictly inside [0.10, 0.90] are included in the fit:

    • Below 10 % the signal is dominated by current-sensor noise and the ADC offset-calibration residual, and ln(1 - ratio) -ratio is close to zero — a poor, noise-sensitive contributor to the fit.

    • Above 90 % the argument of the logarithm approaches zero, so ln(1 - ratio) approaches -∞ — a singularity that would let a single near-steady-state sample dominate (and destabilize) the fit.

    Restricting the fit to the clean 10–90 % transient region yields a numerically well-conditioned, noise-robust estimate of τ.

  9. Validate the fit. If Σ tₙ² < 1×10⁻²⁰ (degenerate/empty fit) or Σ tₙ·yₙ 0 (wrong-signed slope — current did not rise as expected), return 0.0f.

  10. Compute and return the loop inductance L = τ · R_eff directly — no further conversion is applied (see next section for why).

Reporting — line-to-line, not per-phase

The quantity τ·R_eff is the apparent line-to-line inductance of the excited loop (driven phase in series with the parallel combination of the other two phases’ low sides — the same topology, and the same kind of quantity, that the RS test measures resistance for: Ruv/Rvw/Rwu). The function returns this value as-is:

float tau = -sum_tt / sum_ty;
return tau * r_eff_ohm;   /* apparent line-to-line loop inductance */

Earlier revisions multiplied this loop value by a fixed \(\tfrac{2}{3}\) factor to report a “per-phase” inductance, on the textbook assumption that \(L_{phase} = \tfrac{2}{3} L_{loop}\) for a balanced, star-connected winding. That conversion has been removed: this measurement, on its own, cannot tell whether the motor under test is actually star- or delta-connected, nor whether its three phases are balanced — applying the factor unconditionally would silently bake an unverifiable assumption into the reported number, and would produce a wrong per-phase value for any motor that doesn’t happen to match it. Reporting the directly-measured loop quantity — labelled Luv/Lvw/Lwu, exactly mirroring how the RS test reports Ruv/Rvw/Rwu rather than per-phase resistances — keeps the result assumption-free and traceable to what was actually measured. Any phase-equivalent conversion, if ever needed, belongs downstream (e.g. in BUILD_TEMPORARY_MOTOR_MODEL) where the winding topology may be known or assumed explicitly, not baked silently into the raw measurement.

Returns 0 on failure

ls_measure_phase() returns 0.0f — meaning “no usable measurement for this attempt” — in every one of these cases:

Condition

Meaning

r_eff_ohm < 0.001 Ω

Defensive guard — should not occur in practice now that r_eff_ohm always includes RS_SWITCH_RESISTANCE_OHM for measured phases

Capture loop times out repeatedly, yielding

fewer than LS_SS_TAIL + 2 (8) samples

ADC ISR not posting samples / open winding

i_ss < 0.01 A

Steady-state current too small to be meaningful (open circuit / no real injection)

Σ tₙ² < 1×10⁻²⁰ or Σ tₙ·yₙ 0

Degenerate or wrong-signed fit — no clean rising transient was captured in the 10–90 % window

A single 0 result is excluded from the repetition average by ls_measure_phase_avg() (see Repeated measurement and averaging, logged as (capture failed)); only if every repetition for a phase returns 0 does the final averaged hc->ls.l_x_H become 0 and propagate into the per-phase summary line and the final LS: report.

Timing

All Ls-test timing/configuration constants and globals are defined at the top of Src/health_check_sm.c:

Constant / global

Value

Purpose

LS_NUM_SAMPLES

24

ADC samples captured per phase, timed with the DWT cycle counter — spans roughly 800 µs at the 30 kHz PWM/ADC update rate (PWM_FREQUENCY in Inc/drive_parameters.h)

LS_SS_TAIL

6

Number of trailing samples averaged to estimate the steady-state current I_ss

g_ls_imbalance_thresh

1–100 %, default 15 %

Maximum allowed spread between the largest and smallest of the three line-to-line inductances, relative to the smallest (LS:IMBTOL:<pct>)

g_ls_repeat_count

1–25, default 1

Number of capture repetitions per phase, averaged for the reported result (LS:REPEAT:<count>)

Additional timing characteristics worth noting:

  • The per-sample busy-wait timeout is 200 000 loop iterations — large enough to comfortably span one PWM/ADC period at the system clock rate while still bounding the worst case if the ADC ISR stalls.

  • The injected duty cycle is not independently configurable for the Ls test — it is hc->rs.max_duty_pct, the highest duty the RS test’s ramp reached (see Injection duty — inherited from the Rs ramp).

  • Each capture runs to completion synchronously inside ls_measure_phase() — there is no settle/measure split across scheduler ticks as in the RS test’s ramp. The capture window itself (LS_NUM_SAMPLES × ADC period ≈ 800 µs) plus injection setup and PWM shutdown dominate a single capture’s duration; with g_ls_repeat_count repetitions per phase, three phases, and the baseline calibration, all running back-to-back within the LDQ_TEST state, the overall duration scales roughly linearly with the repetition count.

Imbalance detection

Once all three phases have been measured (entering LS_SUB_DONE), the firmware compares the three resulting (averaged) inductances directly (no open/closed filtering is performed here — a phase whose RS measurement failed will already have produced l_x_H = 0 — either because it was skipped outright as open, or because every repetition’s capture failed — which naturally dominates the spread calculation and triggers the imbalance flag):

float l_min = hc->ls.l_u_H, l_max = hc->ls.l_u_H;
/* l_min/l_max updated against l_v_H and l_w_H */

hc->ls.imbalance = (l_min > 1.0e-6f) &&
                   ((l_max - l_min) / l_min > g_ls_imbalance_thresh);

In words: imbalance is flagged when the spread between the largest and smallest of the three line-to-line inductances exceeds the configurable tolerance ``g_ls_imbalance_thresh`` (UART command ``LS:IMBTOL:<pct>``, default ``LS_IMBALANCE_THRESH_DEFAULT`` = 15 %), expressed as a fraction of the smallest, provided the smallest is non-trivially above zero (> 1 µH, screening out the degenerate all-zero case where every measurement failed).

Overall verdict and reporting

LS_SUB_DONE emits, over the UART log (SimpleUART_Log):

  1. A machine-parsable result line, with the apparent line-to-line inductances (Luv, Lvw, Lwu — labelled U/V/W for the driven phase, exactly mirroring the RS: line’s Ruv/Rvw/Rwu convention) expressed in integer microhenries:

    LS:U:<uH> V:<uH> W:<uH> uH[ IMBALANCE]
    

    parsed by the desktop GUI (tools/uart_console/uart_gui.py, “LS Test Results (line-to-line inductance)” panel) in the same way as the RS: line.

  2. A pass/fail line:

    • [LS] All phases OK  PASS — when no inductance imbalance was detected.

    • [LS] FAIL inductance imbalance detected — otherwise.

State transition

LS_SUB_DONE unconditionally transitions the top-level state machine to HC_STATE_BUILD_TEMPORARY_MOTOR_MODEL (DESC_083, currently a placeholder that is intended to build a conservative motor model from the Rs and Ls results). As with the RS test, the Ls test does not raise a critical fault on imbalance or measurement failure — these are surfaced only through the logged flags and the LS: report line, to be consolidated later in REPORT_GENERATION.

Dependency on the Rs test

The Ls test cannot run meaningfully without the results of RS_TEST, and is coupled to it in three distinct ways:

  • Resistance. It reuses hc->rs.r_u_ohm / r_v_ohm / r_w_ohm — the apparent (switch-corrected) line-to-line resistances — as the basis for the loop resistance R in τ = L/R, but adds RS_SWITCH_RESISTANCE_OHM back first, since the physical RL transient it measures is still loaded by the switches that the RS test’s reported value has had subtracted out (see The switch-resistance correction, in reverse). No further per-phase combination is needed beyond that — the RS value already represents the total series resistance of the same injection-path topology (R_drive + R_return1 R_return2).

  • Open-phase skipping. A phase flagged open by the RS test (hc->rs.open_u / open_v / open_w) is not measured at all — hc_state_ldq_test() stores l_x_H = 0 directly and logs [LS] <phase>: skipped (open circuit on Rs test), rather than letting ls_measure_phase() attempt (and fail) a capture on a winding already known to be disconnected.

  • Injection duty. The Ls step is driven at hc->rs.max_duty_pct — the highest duty the RS test’s ramp reached for any phase — so both tests characterize the motor at (as nearly as possible) the same operating point (see Injection duty — inherited from the Rs ramp).

See Stator Resistance (Rs) Calculation for the full RS procedure, the precise meaning of the resistance values it produces, the switch-resistance correction, and how max_duty_pct is tracked during the ramp.

Source reference

  • Inc/health_check_sm.hHC_LsTest_t result structure, HC_STATE_LDQ_TEST enum value, and the configurable-parameter globals g_ls_imbalance_thresh / g_ls_repeat_count. Also HC_RsTest_t.max_duty_pct (the cross-test link consumed here).

  • Src/health_check_sm.c:

    • LS configuration constants and defaults (LS_NUM_SAMPLES, LS_SS_TAIL, LS_IMBALANCE_THRESH_DEFAULT, LS_REPEAT_DEFAULT/LS_REPEAT_MIN/LS_REPEAT_MAX)

    • LS sub-state IDs (LS_SUB_BASELINELS_SUB_DONE)

    • ls_get_phase_current() — phase-current accessor by index

    • ls_measure_phase() — injection, capture, curve fit, and line-to-line loop-inductance computation (now parameterized by duty cycle; no per-phase conversion is applied — see Reporting — line-to-line, not per-phase)

    • ls_measure_phase_avg() — repetition/averaging wrapper around ls_measure_phase()

    • hc_state_ldq_test() — the LDQ_TEST state handler / sub-state machine

    • rs_apply_injection() / rs_ramp_measure_step() — shared injection and ramp helpers, including the max_duty_pct tracking (see Stator Resistance (Rs) Calculation)

  • Src/simple_uart.c — UART commands LS:IMBTOL, LS:REPEAT.

  • tools/uart_console/uart_gui.py — “LS Test Configuration” panel (imbalance tolerance and repetition controls) and “LS Test Results” panel.

  • Inc/drive_parameters.hPWM_FREQUENCY (30 kHz ADC/PWM update rate referenced in the sample-count timing estimate).