Stator Resistance (Rs) 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 winding resistance during the RS_TEST state (HC_STATE_RS_TEST). The implementation lives in ../../Src/health_check_sm.c (functions rs_apply_injection, rs_ramp_measure_step and hc_state_rs_test), with the result structure HC_RsTest_t declared in Inc/health_check_sm.h.

The RS test runs immediately after PASSIVE_MOTOR_CHECK and before LDQ_TEST in the nominal health-check sequence:

IDLE → HARDWARE_SELF_CHECK → PASSIVE_MOTOR_CHECK → RS_TEST → LDQ_TEST → ...

Its purposes are to (a) characterize the apparent line-to-line resistance of each injection path (Ruv, Rvw, Rwu), (b) detect open windings, (c) detect resistance imbalance across phases, (d) optionally flag deviation from a user-supplied nominal resistance, and (e) hand a known total-loop resistance to the subsequent LDQ_TEST (Ls) measurement, which reuses it directly (see Stator Inductance (Ls) Calculation).

Important

The values stored in r_u_ohm / r_v_ohm / r_w_ohm are not per-phase winding resistances. With a star-connected motor, driving one phase’s high side while the other two phases’ low sides conduct in parallel makes the measured loop a line-to-line resistance — the GUI labels these results Ruv, Rvw and Rwu accordingly (driven phase U → Ruv, V → Rvw, W → Rwu).

Measurement principle — ramped DC current injection

The motor is stationary (no PWM active) when the test begins. For each phase in turn, the firmware applies a PWM duty cycle to that phase’s high-side leg while the other two phases’ low-side switches are turned on, forming a return path through the windings. This is implemented by rs_apply_injection(), which now takes the duty cycle as a parameter so the calling state machine can ramp it step by step:

static void rs_apply_injection(uint8_t drive_phase, uint8_t duty_pct)
{
    PWMC_SwitchOffPWM(pwmcHandle[M1]);
    PWMC_TurnOnLowSides(pwmcHandle[M1], 0u);  /* all CCRs=0, MOE enabled */

    uint32_t ticks = (uint32_t)duty_pct * (uint32_t)PWM_PERIOD_CYCLES / 100U;
    /* set CCRx of the drive phase (TIM1 CH1=U, CH2=V, CH3=W) to `ticks`,
       with preload disable → set compare → preload enable, so the new
       duty applies immediately rather than at the next update event */
}

The drive-phase mapping is:

drive_phase

Phase

Timer channel driven

0

U

TIM1 CH1 (LL_TIM_OC_SetCompareCH1)

1

V

TIM1 CH2 (LL_TIM_OC_SetCompareCH2)

2

W

TIM1 CH3 (LL_TIM_OC_SetCompareCH3)

This produces an average injected voltage

\[V_{inj} = V_{bus} \times \frac{duty\,\%}{100}\]

across a series circuit consisting of the driven phase’s winding resistance and the parallel combination of the other two phases’ winding resistances (R_drive + R_return1 R_return2), which is what makes the result a line-to-line value rather than a per-phase one.

Ramping the duty up to the current limit

Rather than injecting at a single fixed duty, the firmware ramps the duty cycle up step by step until the measured current reaches a configurable target, g_rs_ilimit_a — this lets the test push up to roughly 1.5 A of injection current (instead of the few hundred mA a fixed low duty would produce) while still starting gently. The ramp and the final-reading calculation are both implemented in a shared helper, rs_ramp_measure_step():

static bool rs_ramp_measure_step(HC_Handle_t *hc, uint8_t drive_phase,
                                 uint8_t settle_sub, uint32_t now,
                                 float i_now, float *r_ohm)
{
    hc->rs.acc += (i_now < 0.0f ? -i_now : i_now);
    hc->rs.samples++;

    if ((now - hc->rs.tick) < RS_MEASURE_MS) { return false; }

    float I_avg = hc->rs.acc / (float)hc->rs.samples;

    if ((I_avg >= g_rs_ilimit_a) || (hc->rs.duty_pct >= g_rs_duty_max_pct))
    {
        /* Current reached the limit, or the duty is maxed out and the
         * current still never got there — either way this is the final
         * reading. A near-zero current naturally produces a very large
         * apparent resistance, which the open-circuit threshold catches. */
        float vbus       = (float)VBS_GetAvBusVoltage_V(&BusVoltageSensor_M1._Super);
        float i_safe     = (I_avg > 0.001f) ? I_avg : 0.001f;
        float r_apparent = vbus * (float)hc->rs.duty_pct / (100.0f * i_safe);

        /* Remove the fixed high-side + low-side MOSFET Rds(on) contribution
         * baked into every V/I reading along the injection path. */
        float r_corrected = r_apparent - RS_SWITCH_RESISTANCE_OHM;
        *r_ohm = (r_corrected > 0.0f) ? r_corrected : 0.0f;
        return true;
    }

    /* Headroom remains: step the duty up and settle again before re-measuring */
    uint32_t next_duty = (uint32_t)hc->rs.duty_pct + RS_DUTY_RAMP_STEP_PCT;
    hc->rs.duty_pct = (uint8_t)((next_duty > g_rs_duty_max_pct) ? g_rs_duty_max_pct : next_duty);
    rs_apply_injection(drive_phase, hc->rs.duty_pct);
    hc->rs.acc     = 0.0f;
    hc->rs.samples = 0U;
    hc->rs.tick    = now;
    hc->rs.sub     = settle_sub;
    return false;
}

In words: every measurement window either (a) finds I_avg has reached g_rs_ilimit_a — or the duty has already hit its configurable ceiling g_rs_duty_max_pct — in which case it computes and returns the final apparent resistance, or (b) finds there is still headroom, in which case it bumps the duty up by RS_DUTY_RAMP_STEP_PCT, re-applies the injection, and loops back to the settle sub-state to let the new operating point stabilize before measuring again.

Because the ramp always terminates at g_rs_duty_max_pct even when the current limit is never reached, a high-resistance or open winding still produces a (large) apparent-resistance reading at the maximum allowed duty, rather than leaving the test “stuck”. This is also why a separate current-based open-circuit check is unnecessary — see Open-circuit detection below.

Apparent-resistance formula and the switch-resistance correction

The raw apparent resistance is Ohm’s law on the averaged injection:

\[R_{apparent} = \frac{V_{inj}}{I_{avg}} = \frac{V_{bus} \times duty\,\%}{100 \times I_{avg}}\]

However, the injection path always includes one high-side and one low-side MOSFET (each contributing their Rds(on)) in series with the winding(s) being measured, adding a fixed offset to every apparent V/I reading. This is corrected by subtracting a fixed define, RS_SWITCH_RESISTANCE_OHM (0.75 Ω — the measured combined high-side + low-side switch contribution), clamping the result at zero so that a winding resistance smaller than the switch contribution cannot read negative:

\[R = \max(0,\ R_{apparent} - R_{switch})\]

Applied duty cycle

  • Ramp start: RS_TEST_DUTY_PCT = 5U (5 %) — the duty each phase’s ramp begins from.

  • Ramp step: RS_DUTY_RAMP_STEP_PCT = 1U (1 percentage point per step).

  • Ramp ceiling: configurable via g_rs_duty_max_pct (UART command RS:MAXDUTY:<pct>), range 1–80 %, default RS_DUTY_MAX_PCT_DEFAULT = 80U. The ramp never exceeds this value, whether or not the current limit was reached.

  • Converted to timer compare ticks as ticks = duty% * PWM_PERIOD_CYCLES / 100 and written directly to the active channel’s capture/compare register.

The injected current target is configurable via g_rs_ilimit_a (UART command RS:ILIMIT:<mA>, range 100–2000 mA i.e. 0.1–2.0 A, default RS_ILIMIT_DEFAULT_A). The GUI uses g_rs_ilimit_a and the last-read bus voltage to display the maximum measurable line-to-line resistance as Rmax = Vdc / Ilimit — a winding whose true resistance exceeds this value cannot drive enough current to reach the limit even at maximum duty, and will be reported at (or near) the open-circuit threshold.

Sub-state machine

RS_TEST is internally a sequential sub-state machine, tracked in hc->rs.sub (HC_RsTest_t.sub) and driven once per call to hc_state_rs_test(). The sub-states run in this fixed order:

ID

Sub-state

Action

0

RS_SUB_BASELINE

Calls PhaseCurrent_Calibrate() (PWM still off) to capture the zero-current ADC offset over 16 samples spaced 1 ms apart, so that all subsequent current readings are bias-corrected. Logs [RS] Calibrating current baseline... / [RS] Baseline captured.

1

RS_SUB_SETUP_U

On the first repetition (rep_idx == 0) logs [RS] Injecting U ramping duty toward Ilimit and resets the per-phase accumulator rep_acc. Resets the duty to RS_TEST_DUTY_PCT, calls rs_apply_injection(0, duty) (drive U), latches hc->rs.tick = now and advances to the settle sub-state.

2

RS_SUB_SETTLE_U

Waits until (now - tick) >= g_rs_settle_ms, then resets the accumulator/sample counter and advances to the measure sub-state. Also re-entered by the ramp (see `Ramping the duty up...`_) each time the duty is stepped up.

3

RS_SUB_MEASURE_U

Each scheduler tick: reads Ia and feeds it to rs_ramp_measure_step(). While that helper returns false the ramp continues (looping back through RS_SUB_SETTLE_U); once it returns true with a final corrected resistance r_meas, the value is accumulated into rep_acc and rep_idx is incremented. If rep_idx < g_rs_repeat_count the sub-state machine loops back to RS_SUB_SETUP_U for another repetition; otherwise the average rep_acc / rep_idx is stored in r_u_ohm, the open flag is set (see Open-circuit detection), rep_idx is reset to 0, a summary is logged, and the machine advances to RS_SUB_SETUP_V.

4–6

RS_SUB_SETUP_V / _SETTLE_V / _MEASURE_V

Identical sequence, driving phase V (rs_apply_injection(1, ...), channel CH2) and measuring Ib; produces the averaged r_v_ohm. Advances to RS_SUB_SETUP_W.

7–9

RS_SUB_SETUP_W / _SETTLE_W / _MEASURE_W

Identical sequence, driving phase W (rs_apply_injection(2, ...), channel CH3) and measuring Ic; produces the averaged r_w_ohm. Advances to RS_SUB_DONE.

10

RS_SUB_DONE

Switches PWM off, evaluates open-circuit/imbalance/nominal-deviation checks, logs the per-phase summary line and the pass/fail verdict, then transitions the top-level state machine to HC_STATE_LDQ_TEST.

Each phase therefore goes through the same three-step cycle: setup → settle → measure, where “measure” itself contains an inner ramp loop (re-settling and re-measuring at successively higher duties) and, on top of that, an outer repetition loop that repeats the whole ramp g_rs_repeat_count times before the phase’s result is finalized as an average. The three phases (U → V → W) run back to back, and all three results are evaluated together once collection is complete.

Repeated measurement and averaging

To improve repeatability, each phase’s full ramp-and-measure cycle can be repeated multiple times and the resulting resistance values averaged. This is controlled by the global g_rs_repeat_count (UART command RS:REPEAT:<count>, range 1–25, default RS_REPEAT_DEFAULT = 1U):

  • HC_RsTest_t carries two extra fields for this: rep_idx (the count of repetitions completed so far for the phase in progress) and rep_acc (the running sum of corrected resistance readings for that phase).

  • On entry to a phase’s SETUP sub-state, rep_acc is cleared only on the first repetition (rep_idx == 0); the “Injecting <phase>” log line is likewise only emitted once per phase, so repeated ramps don’t spam the log.

  • Each completed ramp logs an individual reading, e.g. [RS] U rep 3/10: 480mOhm  I:1500mA  duty:62%.

  • Once rep_idx reaches g_rs_repeat_count, the average rep_acc / rep_idx becomes the phase’s reported resistance (r_u_ohm / r_v_ohm / r_w_ohm), rep_idx is reset to 0 for the next phase, and a summary line is logged, e.g. [RS] U: avg 478mOhm over 10 reps (or ... OPEN appended if the averaged value exceeds the open-circuit threshold).

With the default g_rs_repeat_count = 1, the behavior is identical to a single ramp-and-measure pass per phase.

Timing

All RS-test timing constants are defined at the top of Src/health_check_sm.c:

Because the per-phase cycle now ramps the duty up step by step (each step re-running settle + measure) and may repeat several times, the overall RS_TEST duration is variable — it depends on how many ramp steps are needed to reach g_rs_ilimit_a (or the duty ceiling) and on g_rs_repeat_count. A rough estimate for one phase, one repetition, with N ramp steps is N × (g_rs_settle_ms + RS_MEASURE_MS); multiply by g_rs_repeat_count for the full per-phase time, and by three (plus the ~16 ms baseline calibration) for the whole RS_TEST state.

Open-circuit detection

Unlike earlier revisions, open-circuit detection is not based on a separate current threshold. Because the duty ramp always terminates — either when the current reaches g_rs_ilimit_a or when g_rs_duty_max_pct is hit — a winding that cannot draw enough current to reach the limit simply produces a very large apparent resistance at the final (maximum) duty. This is naturally caught by comparing the (averaged) result against a single configurable resistance threshold, g_rs_open_thresh_ohm (UART command RS:OPENTHR:<mOhm>, default RS_OPEN_THRESH_OHM_DEFAULT = 50 Ω):

hc->rs.open_u = (hc->rs.r_u_ohm > g_rs_open_thresh_ohm);
  • If the averaged resistance exceeds g_rs_open_thresh_ohm: the phase is flagged open (hc->rs.open_u / open_v / open_w = true) and the per-phase summary line carries an OPEN suffix, e.g. [RS] U: avg 9999mOhm over 1 rep  OPEN.

  • Otherwise the phase is marked closed (open_x = false).

An open phase is excluded from the resistance value reported in the imbalance comparison and the nominal-deviation check (see below), since an open winding would trivially dominate either spread calculation.

Imbalance detection

Once all three phases have been measured (entering RS_SUB_DONE), the firmware scans the three results and keeps only the valid ones — i.e. those that are not flagged open and whose resistance is greater than 0.001 Ω (a sanity floor that also screens out residual zero values from phases that failed to produce a usable measurement):

float r_min = 1.0e9f, r_max = 0.0f;
bool  any   = false;
/* for each non-open phase with r > 0.001 Ω: track r_min / r_max, any = true */

hc->rs.imbalance = any && (r_min > 0.001f) &&
                   ((r_max - r_min) / r_min > g_rs_imbalance_thresh);

In words: imbalance is flagged when the spread between the largest and smallest valid phase resistance exceeds the configurable tolerance ``g_rs_imbalance_thresh`` (UART command ``RS:IMBTOL:<pct>``, default ``RS_IMBALANCE_THRESH_DEFAULT`` = 20 %), expressed as a fraction of the smallest valid resistance. Phases that are open are excluded from this comparison.

Nominal-resistance deviation check

The user can optionally supply a nominal line-to-line resistance, g_rs_nominal_ohm (UART command RS:NOMINAL:<mOhm>, default RS_NOMINAL_OHM_DEFAULT = 0 — disabled), and an allowed deviation ratio, g_rs_deviation_tol (UART command RS:DEVTOL:<pct>, default RS_DEVIATION_TOL_DEFAULT = 20 %). When the nominal is non-zero, every non-open phase’s averaged resistance is compared against it:

if (g_rs_nominal_ohm > 0.001f)
{
    /* for each non-open phase: */
    float dev     = (r_meas - g_rs_nominal_ohm) / g_rs_nominal_ohm;
    float dev_abs = (dev < 0.0f) ? -dev : dev;
    if (dev_abs > g_rs_deviation_tol)
    {
        /* SimpleUART_Log: "[RS] <phase>: deviates from nominal by <pct>%
           (meas:<mO>mOhm  nominal:<mO>mOhm  tol:<pct>%)" */
    }
}

This check is purely informational — it only emits a log line per phase that deviates beyond tolerance; it does not affect hc->rs.imbalance, the open flags, or the pass/fail verdict. Leaving the nominal at 0 (its default) disables the check entirely.

Overall verdict and reporting

When RS_SUB_DONE runs, the PWM is switched off (PWMC_SwitchOffPWM) and the firmware emits, over the UART log (SimpleUART_Log):

  1. A pass/fail line:

    • [RS] All phases OK  PASS — when no phase is open and no imbalance was detected.

    • [RS] FAIL see RS: line for details — otherwise.

    (The optional nominal-deviation log lines, if any, are emitted just before this verdict and do not themselves affect it.)

  2. A machine-parsable result line, with resistances expressed in integer milliohms (to avoid the %f limitation of the newlib-nano printf used on this target):

    RS:U:<mO> V:<mO> W:<mO> mOhm[ OPEN_U][ OPEN_V][ OPEN_W][ IMBALANCE]
    

    The desktop GUI parses this exact RS: line to populate its report — labelled there as Ruv / Rvw / Rwu (see tools/uart_console/uart_gui.py).

State transition

Regardless of the pass/fail verdict, RS_SUB_DONE unconditionally transitions the top-level state machine to HC_STATE_LDQ_TEST. The RS test does not raise a critical fault (hc->fault_active) on its own — open-circuit and imbalance conditions are surfaced only through the logged flags and the RS: report line, to be consolidated later in REPORT_GENERATION (DESC_085, currently a placeholder).

Relationship to the Ls (LDQ) test

The line-to-line resistances computed here (hc->rs.r_u_ohm / r_v_ohm / r_w_ohm) are passed directly into ls_measure_phase() as the r_eff_ohm argument when LDQ_TEST runs immediately afterwards:

/* 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. */
hc->ls.l_u_H = ls_measure_phase(0U, hc->rs.r_u_ohm);

This works because rs_apply_injection() excites exactly the same circuit topology in both tests (driven phase in series with the parallel combination of the other two), so the loop resistance measured by the RS test is the correct R to use in the Ls test’s τ = L / R relationship. A phase whose RS measurement failed (open circuit, r_eff_ohm < 0.001 Ω) causes ls_measure_phase() to return 0 immediately — the Ls measurement for that phase is skipped in all but name. See Stator Inductance (Ls) Calculation for the full Ls procedure.

Source reference

  • Inc/health_check_sm.hHC_RsTest_t result structure (including duty_pct, rep_idx, rep_acc), HC_STATE_RS_TEST enum value, and the configurable-parameter globals: g_rs_duty_pct, g_rs_duty_max_pct, g_rs_settle_ms, g_rs_ilimit_a, g_rs_open_thresh_ohm, g_rs_imbalance_thresh, g_rs_nominal_ohm, g_rs_deviation_tol, g_rs_repeat_count.

  • Src/health_check_sm.c:

    • RS configuration constants and defaults (RS_TEST_DUTY_PCTRS_REPEAT_DEFAULT/RS_REPEAT_MIN/RS_REPEAT_MAX), including RS_SWITCH_RESISTANCE_OHM (the MOSFET Rds(on) correction)

    • RS sub-state IDs (RS_SUB_BASELINERS_SUB_DONE)

    • rs_apply_injection() — PWM injection helper (also reused by the Ls test), now parameterized by duty cycle

    • rs_ramp_measure_step() — shared ramp/measurement/correction helper

    • hc_state_rs_test() — the RS_TEST state handler / sub-state machine

  • Src/simple_uart.c — UART commands RS:DUTY, RS:ILIMIT, RS:MAXDUTY, RS:SETTLE, RS:OPENTHR, RS:IMBTOL, RS:NOMINAL, RS:DEVTOL, RS:REPEAT.

  • tools/uart_console/uart_gui.py — “RS Test Configuration” panel (spinboxes/Set buttons for every configurable parameter above, plus the live “Max measurable Rs (phase-to-phase)” = Vdc / Ilimit readout) and the “RS Test Results” panel (Ruv / Rvw / Rwu labels).