Stator Inductance (Ls) Calculation ================================== Purpose ------- This document describes, in implementation detail, how the Motor Health Check firmware state machine (DESC_081, :doc:`software_architecture`) measures per-phase 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`` and ``ls_measure_phase``, 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: .. code-block:: text ... → PASSIVE_MOTOR_CHECK → RS_TEST → LDQ_TEST → BUILD_TEMPORARY_MOTOR_MODEL → ... It depends directly on the results of ``RS_TEST`` (see :doc:`rs_calculation`): the per-phase loop resistance computed there is reused, unmodified, as the ``R`` in this test's ``L = τ·R`` calculation. 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: .. math:: 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`` (from the RS test), the loop inductance follows as ``L_eff = τ·R``, and the physical per-phase inductance is then derived from ``L_eff`` (see *Per-phase conversion* below). 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 already-known loop resistance. 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 **synchronously** inside a single call to ``ls_measure_phase()`` — that function busy-waits for, and collects, the whole current-rise capture before returning. .. list-table:: :header-rows: 1 :widths: 8 22 70 * - 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`` - Logs ``[LS] Measuring U...``, calls ``ls_measure_phase(0, hc->rs.r_u_ohm)``, stores the result in ``hc->ls.l_u_H``, logs ``[LS] U: uH``, advances to ``LS_SUB_SETUP_V``. * - 2 - ``LS_SUB_SETUP_V`` - Same sequence driving phase V: ``ls_measure_phase(1, hc->rs.r_v_ohm)`` → ``hc->ls.l_v_H``. Advances to ``LS_SUB_SETUP_W``. * - 3 - ``LS_SUB_SETUP_W`` - Same sequence driving phase W: ``ls_measure_phase(2, hc->rs.r_w_ohm)`` → ``hc->ls.l_w_H``. Advances to ``LS_SUB_DONE``. * - 4 - ``LS_SUB_DONE`` - Computes the imbalance flag, logs the ``LS:`` summary line and the pass/fail verdict, then transitions the top-level state machine to ``HC_STATE_BUILD_TEMPORARY_MOTOR_MODEL``. ``ls_measure_phase()`` step by step ----------------------------------- The function signature is: .. code-block:: c static float ls_measure_phase(uint8_t drive_phase, float r_eff_ohm) where ``drive_phase`` selects U/V/W (0/1/2, same convention as ``rs_apply_injection``) and ``r_eff_ohm`` **must** be the total loop resistance for that phase's injection path, as produced by the RS test (``hc->rs.r_u_ohm`` / ``r_v_ohm`` / ``r_w_ohm``). The procedure is: 1. **Guard against an unusable resistance.** If ``r_eff_ohm < 0.001 Ω`` (i.e. the RS test found this phase open or otherwise failed), return ``0.0f`` immediately — no measurement is attempted. 2. **Enable the DWT cycle counter** for microsecond-resolution timestamps that are independent of the PWM/ADC update rate: .. code-block:: c 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)`` (same helper as the RS test — drive phase at ``g_rs_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: .. code-block:: c 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: .. math:: \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: .. math:: \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: .. code-block:: c 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 the loop inductance** ``L_eff = τ · R_eff`` and convert to a per-phase value (see next section), which is the function's return value. Per-phase conversion -------------------- The quantity ``τ·R_eff`` is the *effective loop inductance* of the excited circuit (driven phase in series with the parallel combination of the other two phases — the same topology the RS test measured resistance for). For a balanced, star-connected three-phase motor, the code converts this loop value to a single phase's self-inductance with a fixed factor: .. code-block:: c float l_eff = tau * r_eff_ohm; return (2.0f / 3.0f) * l_eff; /* per-phase Ls for a balanced star motor */ i.e. :math:`L_{phase} = \tfrac{2}{3} L_{eff}`. This mirrors the analogous ``R_{phase}`` relationship used implicitly by the RS test (the loop value equals 1.5× a single balanced phase value, so a phase value is :math:`\tfrac{2}{3}` of the loop value), keeping the Rs and Ls characterizations dimensionally consistent with each other. Returns ``0`` on failure ------------------------ ``ls_measure_phase()`` returns ``0.0f`` — meaning "no usable measurement" — in every one of these cases: ================================================ ================================================ Condition Meaning ================================================ ================================================ ``r_eff_ohm < 0.001 Ω`` Phase open / RS measurement invalid; injection current would be unmeasurably small or undefined 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 ``0`` result therefore propagates cleanly into ``hc->ls.l_x_H`` and is visible in both the per-phase log line (``[LS] X: 0uH``) and the final ``LS:`` report line, without needing a separate error code. Timing ------ All Ls-test timing/configuration constants are defined at the top of ``Src/health_check_sm.c``: ================================ ============ ================================================ Constant 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`` ``LS_IMBALANCE_THRESH`` 0.15 (15 %) Maximum allowed spread between the largest and smallest per-phase inductance, relative to the smallest ================================ ============ ================================================ 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 the *same* ``g_rs_duty_pct`` used by the RS test (default 5 %, see :doc:`rs_calculation`); the Ls test does not apply its own separate duty. * Each phase's measurement runs to completion synchronously inside ``ls_measure_phase()`` — there is no settle/measure split across scheduler ticks as in the RS test. The capture window itself (``LS_NUM_SAMPLES`` × ADC period ≈ 800 µs) plus injection setup and PWM shutdown dominate the per-phase duration; three phases plus the baseline calibration run back-to-back within the ``LDQ_TEST`` state. Imbalance detection ------------------- Once all three phases have been measured (entering ``LS_SUB_DONE``), the firmware compares the three resulting inductances directly (no open/closed filtering is performed here — a phase whose RS measurement failed will already have produced ``l_x_H = 0``, which naturally dominates the spread calculation and triggers the imbalance flag): .. code-block:: c 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 > LS_IMBALANCE_THRESH); In words: **imbalance is flagged when the spread between the largest and smallest per-phase inductance exceeds 15 % 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 inductances expressed in integer microhenries: .. code-block:: text LS:U: V: W: uH[ IMBALANCE] parsed by the desktop GUI (``tools/uart_gui``) 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 valid RS results: * It reuses ``hc->rs.r_u_ohm`` / ``r_v_ohm`` / ``r_w_ohm`` *as-is* as the loop resistance ``R`` in ``τ = L/R`` — these already represent the total series resistance of the same injection-path topology (``R_drive + R_return1 ∥ R_return2``), so no further combination is needed (see the comment in ``hc_state_ldq_test()``: *"r_u_ohm from the Rs test already equals R_U + R_V||R_W — the total series resistance for this injection path. Do not re-add terms."*). * If a phase's RS measurement failed (open circuit → ``r_x_ohm == 0``), ``ls_measure_phase()`` detects ``r_eff_ohm < 0.001 Ω`` and returns ``0`` immediately, without attempting an injection on that phase. See :doc:`rs_calculation` for the full RS procedure and the precise meaning of the resistance values it produces. Source reference ---------------- * ``Inc/health_check_sm.h`` — ``HC_LsTest_t`` result structure, ``HC_STATE_LDQ_TEST`` enum value. * ``Src/health_check_sm.c``: * LS configuration constants (``LS_NUM_SAMPLES``, ``LS_SS_TAIL``, ``LS_IMBALANCE_THRESH``) * LS sub-state IDs (``LS_SUB_BASELINE`` … ``LS_SUB_DONE``) * ``ls_get_phase_current()`` — phase-current accessor by index * ``ls_measure_phase()`` — injection, capture, curve fit, and per-phase inductance computation * ``hc_state_ldq_test()`` — the LDQ_TEST state handler / sub-state machine * ``rs_apply_injection()`` — shared injection helper (see :doc:`rs_calculation`) * ``Inc/drive_parameters.h`` — ``PWM_FREQUENCY`` (30 kHz ADC/PWM update rate referenced in the sample-count timing estimate).