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 R–L
circuit that starts at zero current, the current rises exponentially toward
its steady-state value:
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_pct — the 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 |
|
Calls |
1 |
|
If |
2 |
|
Same sequence driving phase V ( |
3 |
|
Same sequence driving phase W ( |
4 |
|
Computes the imbalance flag (against the configurable
|
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:
Guard against an unusable resistance. If
r_eff_ohm < 0.001 Ω, return0.0fimmediately — no measurement is attempted. (In practice this is now mostly a defensive check: open phases are filtered out earlier, inhc_state_ldq_test(), by the RS test’sopen_xflags, andr_eff_ohmalways includes the non-zero switch-resistance term for any phase that is measured.)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”.
Apply the step injection with
rs_apply_injection(drive_phase, duty_pct)(same helper as the RS test — drive phase atduty_pctduty, the other two phases’ low sides on as the return path) and record the injection-start timestampt0 = DWT->CYCCNT.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) / SystemCoreClockseconds — 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 buffersi_buf/t_buf.
ls_get_phase_current()simply mapsdrive_phase(0/1/2) ontoPhaseCurrent_Read().ia_A/ib_A/ic_A.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.Sanity-check the capture. If fewer than
LS_SS_TAIL + 2(8) valid samples were collected, return0.0f(capture failed/timed out).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_ssfrom the measured waveform itself — rather than computing it asV_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.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 belowI_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_ssfalls 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) ≈ -ratiois 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τ.Validate the fit. If
Σ tₙ² < 1×10⁻²⁰(degenerate/empty fit) orΣ tₙ·yₙ ≥ 0(wrong-signed slope — current did not rise as expected), return0.0f.Compute and return the loop inductance
L = τ · R_effdirectly — 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 |
|---|---|
|
Defensive guard — should not occur in practice
now that |
Capture loop times out repeatedly, yielding |
|
fewer than |
ADC ISR not posting samples / open winding |
|
Steady-state current too small to be meaningful (open circuit / no real injection) |
|
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 |
|---|---|---|
|
24 |
ADC samples captured per phase, timed with the
DWT cycle counter — spans roughly 800 µs at the
30 kHz PWM/ADC update rate ( |
|
6 |
Number of trailing samples averaged to estimate
the steady-state current |
|
1–100 %, default 15 % |
Maximum allowed spread between the largest and
smallest of the three line-to-line inductances,
relative to the smallest ( |
|
1–25, default 1 |
Number of capture repetitions per phase,
averaged for the reported result
( |
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; withg_ls_repeat_countrepetitions per phase, three phases, and the baseline calibration, all running back-to-back within theLDQ_TESTstate, 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):
A machine-parsable result line, with the apparent line-to-line inductances (Luv, Lvw, Lwu — labelled
U/V/Wfor the driven phase, exactly mirroring theRS: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 theRS:line.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 resistanceRinτ = L/R, but addsRS_SWITCH_RESISTANCE_OHMback 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()storesl_x_H = 0directly and logs[LS] <phase>: skipped (open circuit on Rs test), rather than lettingls_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.h—HC_LsTest_tresult structure,HC_STATE_LDQ_TESTenum value, and the configurable-parameter globalsg_ls_imbalance_thresh/g_ls_repeat_count. AlsoHC_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_BASELINE…LS_SUB_DONE)ls_get_phase_current()— phase-current accessor by indexls_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 aroundls_measure_phase()hc_state_ldq_test()— the LDQ_TEST state handler / sub-state machiners_apply_injection()/rs_ramp_measure_step()— shared injection and ramp helpers, including themax_duty_pcttracking (see Stator Resistance (Rs) Calculation)
Src/simple_uart.c— UART commandsLS: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.h—PWM_FREQUENCY(30 kHz ADC/PWM update rate referenced in the sample-count timing estimate).