MEMS Sensor Calibration: Offset, Scale Factor and Cross-Axis Compensation

MEMS inertial sensors will never give you the accuracy printed on the datasheet without a proper calibration routine. I learned this the hard way on an early asset tracking project using an LSM6DS3 — the raw accelerometer reported 1.07g at rest and the gyro drifted 3 degrees per minute even on a perfectly stable bench. Factory trimming gets you close, but residual offset, scale factor errors, and cross-axis misalignment will dominate your error budget unless you characterize and compensate for them in the field. In this article, I'll walk through the complete calibration model I use for both accelerometers and gyroscopes, how to solve for the parameters with and without lab equipment, and how to implement the correction efficiently on a Cortex-M class MCU.

Why Factory Trim Isn't Enough: Deconstructing MEMS Error Sources

In my experience, the most common mistake teams make is treating the accelerometer and gyroscope as ideal orthogonal sensors. A real MEMS structure suffers from at least five deterministic error sources that are repeatable enough to calibrate out. The dominant ones are zero-g offset (bias), scale factor error, cross-axis sensitivity, misalignment to the package/PCB, and temperature-dependent drift. Solder reflow alone can shift offset by 20-40 mg for an accelerometer and several dps for a gyroscope due to package stress, which is why the values you measured on the evaluation board rarely match what you see on your production board.

The general error model that covers all three linear errors is often written as:

Raw = T * S * M * (True + Bias) + Noise

Where S is a diagonal scale factor matrix, M is the misalignment / cross-axis matrix, T is temperature dependence, and Bias is the offset vector. For most IoT products we can simplify temperature out for the initial calibration and combine S and M into a single 3x3 correction matrix. More usefully for firmware implementation, we invert it to a compensation form:

Calibrated = C * (Raw - Offset)

Here Offset is a 3x1 vector [bx, by, bz] and C is a 3x3 matrix that corrects both scale and misalignment in one operation. When C is purely diagonal, you are only correcting scale factor. When it has off-diagonal terms, you are correcting cross-axis coupling as well. I've found that skipping the off-diagonal correction leaves 1-2% tilt error in attitude estimation, which becomes very visible in applications like construction tool leveling or drone AHRS.

How Soldering and Mechanical Stress Shift Calibration

Don't calibrate modules before reflow. On one production run with the ICM-42688-P, I measured accelerometer Z-axis offset at -18 mg on the bare module, and -61 mg after reflow at 245C peak. That 43 mg shift is enough to cause a 2.5 degree pitch error if uncorrected. Best practice is to perform a final calibration after assembly, enclosure, and a 48-hour bake at room temperature to let mechanical stress relax. If your device will see wide temperature swings, you also need to characterize bias vs temperature and store a lookup table or linear coefficients.

Separating Stochastic Noise from Deterministic Errors

Before you calibrate, you need to understand what you cannot calibrate. In-run bias instability and white noise are random and must be filtered, not compensated. I typically collect 2 hours of static data and compute Allan variance to find the bias instability floor. For a consumer-grade gyro this is often 10-20 deg/hr. That tells you the best bias estimate you can sustain. If your application needs heading hold for minutes without external reference, you will need a higher-grade sensor or sensor fusion regardless of how good your offset calibration is.

Accelerometer Calibration: The Six-Position Tumble Test and Ellipsoid Fitting

An ideal three-axis accelerometer measuring only gravity will trace a perfect sphere of radius 1g as you rotate it through all orientations. A real, uncalibrated sensor traces an offset, scaled, and skewed ellipsoid. Calibration is therefore an ellipsoid-to-sphere fitting problem: find the offset vector and 3x3 matrix that best maps the measured ellipsoid back to a sphere.

The simplest field method is the classic six-position tumble test. You place each axis aligned with and against gravity and record the static average. That gives you six measurements: +X, -X, +Y, -Y, +Z, -Z. Offset and scale are then trivial to solve per-axis:

Bias_x = (Raw_+X + Raw_-X) / 2
ScaleFactor_x = 2 * G / (Raw_+X - Raw_-X)

This works surprisingly well if your mechanics allow precise 90-degree fixturing. I've used a machined aluminum cube that the PCB bolts into for this on small batches. The problem is that it assumes misalignments are negligible. It cannot solve cross-axis terms because you never excite off-diagonal coupling in a pure six-position test. It also amplifies error if your cube or table isn't perfectly level — a 1 degree surface error injects ~17 mg error into the horizontal axes.

For production calibration without precision fixturing, I prefer a multi-position least-squares method collecting 30-50 random static orientations. The sensor just needs to be held still for 1-2 seconds per orientation while you capture averaged samples. This data is then fitted to the ellipsoid general equation:

((x - bx)/s_x)^2 + ((y - by)/s_y)^2 + ((z - bz)/s_z)^2 + cross_terms = G^2

With cross-terms included, this becomes a linear least-squares problem solvable on-device or in Python during factory tooling. According to the Zephyr Project Documentation, the Zephyr sensor subsystem can provide calibrated sampling via the `SENSOR_ATTR_OFFSET` and matrix attributes, but I still prefer to do the math offline and flash final coefficients.

Implementing Ellipsoid Fitting in the Factory Jig

In practice I collect data via a UART or RTT stream while an operator tumbles the device slowly by hand inside an ESD-safe fixture. The key is to reject any sample where the magnitude variance during the averaging window exceeds a threshold, which indicates motion. You want only static windows. The data is then sent to a Python script using NumPy to solve the 9-parameter fit. The result is a bias vector and a symmetric 3x3 matrix that you decompose into scale and misalignment if needed.

// Accelerometer static window collection
// Call at 100 Hz, average 100 samples = 1 second static window
#define ACCEL_WINDOW_SIZE 100

typedef struct {
  int32_t sum_x, sum_y, sum_z;
  int32_t sum_sq_x, sum_sq_y, sum_sq_z;
  uint16_t count;
} static_window_t;

bool accel_collect_static_sample(static_window_t *w, int16_t x, int16_t y, int16_t z) {
  w->sum_x += x;
  w->sum_y += y;
  w->sum_z += z;
  w->sum_sq_x += (int32_t)x * x;
  w->sum_sq_y += (int32_t)y * y;
  w->sum_sq_z += (int32_t)z * z;
  w->count++;

  if (w->count >= ACCEL_WINDOW_SIZE) {
    int32_t mean_x = w->sum_x / ACCEL_WINDOW_SIZE;
    int32_t var_x = (w->sum_sq_x / ACCEL_WINDOW_SIZE) - mean_x * mean_x;
    // Reject window if variance > threshold (sensor was moving)
    // Threshold of ~200 LSB^2 for a 16-bit sensor at +/-2g is about 5 mg RMS
    if (var_x > 200 || var_y > 200 || var_z > 200) {
      memset(w, 0, sizeof(*w));
      return false;
    }
    // Window is valid - store mean_x, mean_y, mean_z for fitting
    store_cal_point(mean_x, mean_y, mean_z);
    memset(w, 0, sizeof(*w));
    return true;
  }
  return false;
}

One practical tip: scale your raw ADC values to mg before fitting. Numerically, fitting a sphere of radius 16384 LSB gives poor conditioning. Converting to float in g units (radius 1.0) improves solver stability dramatically, especially when solving on a Cortex-M4F without double precision.

Gyroscope Bias and Scale Factor: From Allan Variance to Thermal Modeling

Gyroscopes are fundamentally different from accelerometers because there is no convenient absolute reference like gravity. You cannot calibrate gyro scale factor with a tumble test unless you have a precision rate table. For most IoT devices, the gyroscope calibration focuses heavily on zero-rate offset (bias) and its drift over temperature and time.

Zero-rate bias is what you measure when the device is perfectly stationary. Even a high-quality MEMS gyro will show 0.5 to 2 dps bias at room temperature after factory trim, and this bias will walk by 0.1 dps per degree Celsius. In my experience, if you don't compensate for this, a simple integration for yaw will drift over 10 degrees in 30 seconds. For devices that need to detect motion vs stillness, like asset trackers or wearable fall detectors, this bias is the difference between a reliable system and one that wakes up constantly on false triggers.

Characterizing Bias Instability Before You Compensate

Before coding a calibration routine, measure what you are dealing with. I log raw gyro data at 200 Hz for two hours on a vibration-isolated granite slab and compute Allan deviation. The curve reveals three regions: angle random walk (slope -1/2), bias instability (flat minimum), and rate random walk (slope +1/2). The minimum of the curve is the best bias stability you can achieve with averaging. If that floor is 8 deg/hr, averaging longer than ~100 seconds provides no benefit. This tells you how long your run-time auto bias routine needs to average.

For field calibration without a rate table, scale factor can often be left at factory default unless you need better than 1-2% accuracy. If you do need scale calibration, I've found that a simple controlled 360-degree turn on a lazy Susan with a reference encoder or even a smartphone compass as ground truth can get you to ~0.5% scale accuracy for the Z-axis. It's crude but often sufficient for indoor navigation.

Temperature-Dependent Bias Compensation

The most effective improvement I've made to low-cost gyro performance is a first-order temperature compensation. Most modern IMUs like the LSM6DS or ICM series have an on-die temperature sensor accurate to +/-1C. During factory calibration, I soak the device in a chamber at 0C, 25C, and 45C, measuring bias at each point while stationary. Then I fit a linear or quadratic model: Bias(T) = b0 + b1*T + b2*T^2. In firmware, I read die temperature at 1 Hz and apply the correction before filtering. This alone reduced drift in one cold-chain logger from 8 deg/min to under 1 deg/min across -10C to 40C.

typedef struct {
  float b0[3]; // bias at 25C for X,Y,Z in dps
  float b1[3]; // linear tempco in dps/C
  float temp_ref; // e.g., 25.0f
} gyro_temp_cal_t;

gyro_temp_cal_t gcal = {
  .b0 = {0.85f, -1.12f, 0.42f},
  .b1 = {0.015f, -0.022f, 0.008f},
  .temp_ref = 25.0f
};

void gyro_apply_temp_comp(float *gx, float *gy, float *gz, float temp_c) {
  float dt = temp_c - gcal.temp_ref;
  *gx -= (gcal.b0[0] + gcal.b1[0] * dt);
  *gy -= (gcal.b0[1] + gcal.b1[1] * dt);
  *gz -= (gcal.b0[2] + gcal.b1[2] * dt);
}

// Run-time zero-rate bias learning when device is detected stationary
void gyro_update_run_time_bias(float gx, float gy, float gz, bool is_stationary) {
  static float bias_lp[3] = {0};
  const float alpha = 0.005f; // Very slow LPF, ~200 sec time constant at 100 Hz
  if (is_stationary) {
    bias_lp[0] = (1.0f - alpha) * bias_lp[0] + alpha * gx;
    bias_lp[1] = (1.0f - alpha) * bias_lp[1] + alpha * gy;
    bias_lp[2] = (1.0f - alpha) * bias_lp[2] + alpha * gz;
  }
}

Be careful not to learn bias when the device is actually moving slowly. I gate the run-time update with an accelerometer-based stillness detector: if the variance of the accelerometer is below 10 mg and gyro magnitude is below 2 dps for at least 2 seconds, only then do I update. Otherwise you will learn away real motion.

Correcting Cross-Axis Misalignment With the 3x3 Compensation Matrix

Cross-axis error is the most misunderstood term in MEMS calibration. It has two components: internal sensor non-orthogonality (the X, Y, Z MEMS structures are not perfectly 90 degrees apart due to silicon etch tolerances) and PCB/package misalignment (the entire sensor die is rotated relative to your enclosure's reference axes). Both manifest as a measurement on one axis when you apply excitation purely on another.

A good way to visualize this is to mount your board on a precision rotary stage and rotate only around Z. If you see sinusoidal response on X and Y as well as Z, you have misalignment. Typical consumer MEMS spec is 0.5% to 1% cross-axis sensitivity, which translates to about 0.5 degrees of axis non-orthogonality. That doesn't sound like much until you try to compute pitch and roll from the accelerometer and find a 1 degree coupling error that varies with yaw.

The full compensation matrix C that corrects all three effects at once looks like this in firmware:

[cx] [c00 c01 c02] [rx - bx]
[cy] = [c10 c11 c12] * [ry - by]
[cz] [c20 c21 c22] [rz - bz]

Where [rx, ry, rz] is raw, [bx, by, bz] is offset, and [cx, cy, cz] is calibrated output in g or dps. If you solve ellipsoid fitting without constraint, the solver returns a symmetric 3x3 matrix that you then invert to get C. I store C as 9 floats in little-endian order in flash. For a Cortex-M0 that lacks FPU, I convert C to Q14 fixed point and use 32-bit integer math to stay within cycle budget.

Connection details matter here as well. For high-rate sampling needed during calibration — typically 200 Hz to 1 kHz to get good statistics — the bus choice affects data integrity. While I2C Protocol Tutorial: Addressing, Clock Stretching and Multi-Master covers robustness for lower rates, I strongly recommend SPI Communication: Full-Duplex Data Transfer for High-Speed Sensors for any IMU calibration jig because it avoids clock stretching issues and lets you read the IMU and a temperature sensor in the same burst DMA transaction.

Error Term Typical Magnitude (Consumer MEMS) Calibration Method Field Recalibration Needed?
Zero-g / Zero-rate Offset 30-80 mg (Accel), 0.5-2.0 dps (Gyro) Six-position tumble or ellipsoid fit (accel), long static average or temp model (gyro) Yes, after reflow and periodically due to aging
Scale Factor Error 1-3% from nominal Known-g reference or rate table, ellipsoid fit for accel No, stable unless temperature range exceeds characterization
Cross-Axis / Misalignment 0.5-1.0% coupling (-0.3 to 0.6 deg) Multi-position ellipsoid fit with full 3x3 matrix Only if PCB stress changes or enclosure remounted
Temperature Drift (Bias) 0.2-0.5 mg/C, 0.01-0.05 dps/C Thermal chamber soak and linear/quadratic fit Yes, if operating outside factory temp characterization

Implementing Run-Time Calibration on Resource-Constrained MCUs

Factory calibration is only half the job. In the field, bias drifts with age, temperature, and supply voltage. You need a lightweight run-time correction that applies the stored coefficients without burning excessive cycles or power. My standard approach stores factory coefficients in NVS (emulated EEPROM or dedicated flash page) and applies them in the sensor ISR or a high-priority sensor task.

On FreeRTOS-based firmware, I run the IMU read at high priority but the compensation at the same task to avoid context switch overhead. Using a DMA-driven SPI read triggered at 200 Hz, I get raw data in a double buffer, apply integer compensation, and push calibrated data to a queue for the fusion algorithm. The FreeRTOS Documentation provides excellent guidance on using Direct Task Notifications instead of queues for this single-producer path to save RAM and latency.

Fixed-Point Implementation for Cortex-M0/M3

Floating point matrix multiplication at 200 Hz with 9 multiplies and 6 adds per sensor is trivial on an M4F with FPU (~1.2 microseconds), but on an M0+ it can take 150+ microseconds if you use soft-float. I convert to Q14 fixed point for those parts. Scale the matrix by 16384, store as int16, and do 32-bit MACs. Error from quantization is under 0.06 mg, which is below noise floor. Keep the offset subtraction in raw LSB space to avoid offset quantization.

// Fixed-point accel calibration for Cortex-M0+ (Q14 matrix)
// C_q14[9] = matrix * 16384, offsets in raw LSB
typedef struct {
  int16_t c_q14[9];
  int16_t offset[3];
} accel_cal_fixed_t;

void accel_apply_cal_fixed(const accel_cal_fixed_t *cal,
                           int16_t rx, int16_t ry, int16_t rz,
                           int16_t *cx, int16_t *cy, int16_t *cz) {
  int32_t x = (int32_t)rx - cal->offset[0];
  int32_t y = (int32_t)ry - cal->offset[1];
  int32_t z = (int32_t)rz - cal->offset[2];

  // Q14 multiply then shift >>14 with rounding
  int32_t ox = (cal->c_q14[0]*x + cal->c_q14[1]*y + cal->c_q14[2]*z + 8192) >> 14;
  int32_t oy = (cal->c_q14[3]*x + cal->c_q14[4]*y + cal->c_q14[5]*z + 8192) >> 14;
  int32_t oz = (cal->c_q14[6]*x + cal->c_q14[7]*y + cal->c_q14[8]*z + 8192) >> 14;

  // Saturate to int16 range
  *cx = (ox > 32767) ? 32767 : (ox < -32768 ? -32768 : (int16_t)ox);
  *cy = (oy > 32767) ? 32767 : (oy < -32768 ? -32768 : (int16_t)oy);
  *cz = (oz > 32767) ? 32767 : (oz < -32768 ? -32768 : (int16_t)oz);
}

Managing ADC Resolution and Oversampling During Calibration

One subtle trap is ADC and digital LPF settings. Most MEMS IMUs have internal 16-bit ADCs but apply decimation and filtering. If you calibrate at ±2g range and then switch to ±8g for high-g detection, your scale factors are invalid because the internal LSB/g changes. Always lock the full-scale range and ODR during calibration and operation, or store separate calibration sets per range. For guidance on noise vs resolution tradeoffs that directly affect how well your ellipsoid fit converges, review ADC Sampling Techniques: Resolution, Noise Reduction and Oversampling — the same oversampling principles apply to the IMU's internal sigma-delta path. I've found that averaging 4x oversampled data at 800 Hz down to 200 Hz output reduces the residual fitting error by nearly 30% on noisy power supplies.

Storing and Versioning Calibration Data

Never store raw coefficients without a version header and CRC. I use a struct with magic 0xCA11, version, sensor ID, and CRC32. On boot, if CRC fails, the firmware falls back to identity matrix and zero bias and flags a calibration fault. Also store the temperature at which calibration was performed. If the device later boots 20C away from that point and you have a tempco model, you can immediately apply a corrected bias instead of starting from a cold biased estimate.

Validating Calibration: Residual Error Analysis and Field Verification

A calibration is only as good as its validation. My acceptance criteria for an accelerometer after compensation are: static magnitude error |sqrt(x^2+y^2+z^2) - 1g| < 8 mg across 12 random orientations, and per-axis zero-g error < 5 mg on a level surface plate. For the gyroscope, I check zero-rate bias < 0.05 dps after warm-up and temperature compensation, measured over a 60-second static window with standard deviation < 0.03 dps.

To validate the 3x3 matrix, I run a simple field check that can be done without a jig: hold the device in 6-8 arbitrary stationary poses for 3 seconds each and compute magnitude error. Plot the histogram of magnitudes — a well-calibrated sensor shows a tight Gaussian centered at 1.0g with sigma < 4 mg. A poor cross-axis calibration shows a bimodal or skewed distribution that shifts with orientation. I've caught PCB assembly houses swapping IMU part reels (LSM6DS3 vs LSM6DS3TR-C, which have slightly different trim) exactly this way.

For continuous validation in deployed devices, I log calibration residuals as a diagnostic metric. Every time the device detects a static dwell (e.g., asset tracker parked overnight), I compute the difference between measured gravity magnitude and 1g after compensation and upload the p50/p95 values. A gradual increase over months indicates mechanical stress relaxation or solder creep, and I can trigger a remote recalibration prompt. In one fleet of 2000 cold-chain monitors, this telemetry flagged 3% of units that needed recalibration after a year due to enclosure sealant outgassing pressing on the PCB.

Finally, don't forget to validate under motion. A sensor that looks perfect statically can still show scale errors dynamically. I use a simple pendulum test for accelerometers — a 1-meter string with the sensor on the bob gives a predictable centripetal acceleration up to 2g — and a turntable step test for gyros. If dynamic magnitude error grows linearly with applied rate, your scale factor is still off. Fix it before tuning your Kalman or complementary filter, or the filter will hide the error until temperature changes expose it.

Frequently Asked Questions

Do I need to recalibrate MEMS sensors after soldering and assembly?

Yes. Solder reflow and mechanical mounting stress typically shift accelerometer offset by 20-60 mg and gyro bias by 0.3-1.0 dps compared to bare module values. In my experience you should always perform final calibration after full assembly, enclosure close, and a 24-48 hour stress relaxation period. Factory trim alone is insufficient for accuracy better than about 2 degrees of tilt.

Can I calibrate an accelerometer without a precision cube or rate table?

Absolutely. The multi-position ellipsoid fitting method requires no precise fixturing. You simply hold the device still in 30-50 random orientations for 1-2 seconds each, reject moving windows, and solve for the 9-parameter ellipsoid (3 bias + 6 matrix terms). This solves scale and cross-axis misalignment simultaneously. Tools in Python (NumPy) or even on-device least-squares can achieve under 8 mg magnitude error without any mechanical jig.

How often should run-time gyroscope bias be relearned?

Continuously, but only when stationary. I gate learning with an accelerometer stillness detector (variance <10 mg and gyro <2 dps for at least 2 seconds) and update bias with a very slow low-pass filter (alpha ~0.005 at 100 Hz, about 200 second time constant). This tracks temperature drift without learning away intentional slow rotations. If you update too quickly you will suppress real motion and cause heading lag.

Is cross-axis compensation necessary if I only need tilt detection?

If you need tilt accuracy better than about 1.5 degrees, yes. A 1% cross-axis coupling causes up to ~0.6 degrees of error that varies with yaw orientation, which looks like a hysteresis band in simple threshold tilt detectors. Correcting with a full 3x3 matrix adds only 9 multiplies per sample and removes this orientation-dependent error. For coarse wake-on-motion, you can skip it, but for any attitude estimation you should include it.

Related Articles

References & Standards: FreeRTOS Documentation · Zephyr Project Documentation · MQTT Specification