Embedded HandbookApps
All sections › XI. Sensors: connection, circuits, drivers
STM32F103, STM32F407 · level intermediate · 38 min read
Open in the interactive handbook

99б. IMU orientation: the quaternion, the Madgwick filter and magnetometer calibration

In section 99 you built tilt with a complementary filter: one angle, gyroscope plus accelerometer. There was no heading there — gravity does not change with a turn about the vertical, so you need a magnetometer. Here you get full orientation, all three angles at once: an attitude and heading reference system, AHRS. It is stored in a quaternion, the gyroscope rotates it, and gravity and the magnetic field pull it little by little towards the truth.

Step by step: the quaternion and gyroscope integration; why not Euler angles (the gimbal lock experiment); the Madgwick filter — a gyroscope step plus a step against the error gradient; gyroscope bias found during pauses, and rejection of the accelerometer during jolts; magnetometer calibration — hard iron shifts the centre, soft iron turns the sphere into an ellipsoid, and least squares turns it back into a sphere. All the code uses float; from the math library the filter needs only sqrtf and fabsf, and the Euler angles for display also need atan2f, asinf, sinf and cosf.

Everything was tested on a model where the true motion is known: two minutes of rotation, shaking, a drifting gyroscope bias, a distorted field. The same C code was built for Cortex-M3 and Cortex-M4F, and the instructions per step were counted in QEMU. numpy and scipy recomputed the truth, the filter and the calibration independently.

References: the idea comes from the Fusion library (github.com/xioTechnologies/Fusion, MIT license): a revised Madgwick filter with bias estimation during pauses and acceleration rejection, — and NXP application note AN4246 on magnetometer calibration. Neither the Fusion code nor the text of AN4246 is used in this section, and the section was not checked against them: the formulas were derived anew and verified numerically. For comparison — src/main/flight/imu.c from Betaflight 2026.12.0-alpha (commit 744f95f, license GPL-3.0), the same one as in section 99а; from it there is only a retelling. The section's code is our own: quat99b.c, ahrs99b.c, magcal99b.c; the model is sim99b.c, the cross-check is ref99b.py, the measurement on Cortex-M is bench99b.c.

Terms used in the documentation

Term Meaning
AHRS (attitude and heading reference system) a system that estimates orientation and heading
quaternion four numbers that store a rotation
Euler angles: roll, pitch, yaw three angles: roll, pitch, heading (yaw)
gimbal lock two Euler angles merge into one
gradient descent, step size β gradient descent, correction step β
gyroscope bias, rest detection gyroscope zero offset, detecting that the device is still
acceleration rejection ignoring the accelerometer during accelerations
hard iron, soft iron a constant offset of the field, a distortion of the field by nearby steel
ellipsoid fit, least squares fitting an ellipsoid, the least-squares method
magnetic inclination the angle of the field below the horizontal
FPU, soft-float hardware floating-point unit, floating point done in software

99б.1 What we build and how we test it

Orientation is the rotation from the sensor axes to the Earth axes. The Earth axes here are: x — towards magnetic north, y — west, z — up. A quaternion is four numbers (w, x, y, z) of length 1; a rotation by angle φ about a unit axis n is written as (cos φ/2, n·sin φ/2). A 3×3 matrix is nine numbers that must be kept mutually perpendicular; a quaternion is four, and only the length has to be kept. Why not three angles — see 99б.2.

The filter loop on every sample: the gyroscope rotates the quaternion by ω·dt; the accelerometer says where up is; the magnetometer says where north is; the difference between what the sensors see and what they should see with the current quaternion gives a small correction.

IMU orientation: the gyroscope with bias, the accelerometer and the calibrated magnetometer go through bias estimation during pauses, acceleration rejection and the W·(m − V) calibration, then the Madgwick filter adds the gyroscope step and the step against the error gradient; the output is a quaternion, and Euler angles are for display only

The model: 12000 samples at 100 Hz, 120 s. The device lies still for 10 s, then come three rotation segments (10–40, 45–70 and 75–100 s) with sine waves up to 60, 10 and 45 °/s on the axes, with 5 s pauses between them, and 20 s of rest at the end. From 85 to 95 s the device is also shaken along north: up to 0.5 g at 2 Hz. The largest pitch is 82.1°. The truth is integrated in double in 20 small steps per sample; scipy recomputed it its own way, and the difference is 3.30·10⁻¹² degrees.

Sensors. Gyroscope: noise 0.05 °/s and a bias that drifts, as during warm-up, from 0.50; −0.30; 0.40 to 0.98; 0.06; −0.20 °/s. Accelerometer: noise 0.005 g. Magnetometer: a 50 µT field with 70° inclination, noise 0.3 µT, hard iron V = (25; −12; 40) µT and soft iron — a symmetric matrix A with diagonal 1.15; 0.92; 1.03. From the residuals "reading minus what the truth predicts" numpy found noise of 0.0501 °/s, 0.00500 g and 0.2983 µT — the sensors are as specified.

Errors are counted after the first 10 s: tilt is the angle between the true and the estimated "up" direction, heading is the difference of the heading angles. RMS is the root mean square, "max" is the worst sample.


99б.2 The quaternion in C and the gimbal lock of Euler angles

The gyroscope gives the rate in the sensor axes, and the quaternion changes as dq/dt = ½·q ⊗ (0, ω). The step uses the first derivative, followed by normalization: rounding errors accumulate, and the length drifts away from one.

/* quat99b.h — раздел 99б: кватернион ориентации (float, без библиотек, кроме sqrtf/atan2f/asinf).
 * q = (w, x, y, z) поворачивает вектор из осей датчика в оси Земли: v_земля = q ⊗ v ⊗ q*.
 * Оси Земли: x — на магнитный север, y — на запад, z — вверх. */
#ifndef QUAT99B_H
#define QUAT99B_H

typedef struct {
    float w, x, y, z;
} quat_t;

quat_t quat_mul(quat_t a, quat_t b);
quat_t quat_norm(quat_t q);
void quat_rotate(quat_t q, const float v[3], float out[3]);
void quat_rotate_inv(quat_t q, const float v[3], float out[3]);
quat_t quat_gyro_step(quat_t q, const float g[3], float dt);
void quat_to_euler(quat_t q, float *roll, float *pitch, float *yaw);
quat_t quat_from_euler(float roll, float pitch, float yaw);

#endif
/* Произведение a ⊗ b: сначала поворот b, потом a. */
quat_t quat_mul(quat_t a, quat_t b)
{
    quat_t r;
    r.w = a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z;
    r.x = a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y;
    r.y = a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x;
    r.z = a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w;
    return r;
}

/* Привести к длине 1: ошибки округления копятся, и без этого кватернион «распухает». */
quat_t quat_norm(quat_t q)
{
    float n = 1.0f / sqrtf(q.w * q.w + q.x * q.x + q.y * q.y + q.z * q.z);
    q.w *= n;
    q.x *= n;
    q.y *= n;
    q.z *= n;
    return q;
}

/* Шаг по гироскопу: dq/dt = 0,5 · q ⊗ (0, ω), ω в рад/с в осях датчика. */
quat_t quat_gyro_step(quat_t q, const float g[3], float dt)
{
    quat_t w = { 0.0f, g[0], g[1], g[2] };
    quat_t d = quat_mul(q, w);
    float h = 0.5f * dt;
    q.w += d.w * h;
    q.x += d.x * h;
    q.y += d.y * h;
    q.z += d.z * h;
    return quat_norm(q);
}

/* Углы Эйлера в порядке «курс, тангаж, крен» (Z, Y, X), радианы.
 * При тангаже ±90° крен и курс слиты в один поворот — это и есть «замок». */
void quat_to_euler(quat_t q, float *roll, float *pitch, float *yaw)
{
    float s = 2.0f * (q.w * q.y - q.z * q.x);
    if (s > 1.0f)
        s = 1.0f;
    if (s < -1.0f)
        s = -1.0f;
    *roll = atan2f(2.0f * (q.w * q.x + q.y * q.z), 1.0f - 2.0f * (q.x * q.x + q.y * q.y));
    *pitch = asinf(s);
    *yaw = atan2f(2.0f * (q.w * q.z + q.x * q.y), 1.0f - 2.0f * (q.y * q.y + q.z * q.z));
}

The gyroscope alone, starting from the truth, drifted in 120 s by 36.35° in tilt and 17.77° in heading — the bias is integrated, as in 99.6, only now on all three axes.

The Euler angles here are in "heading, pitch, roll" order: first a rotation about z, then y, then x. The sine of pitch is 2(wy − xz); at a pitch of ±90° roll and heading become rotations about the same axis and are not defined separately. Experiment: the device raises its nose at 0.6 rad/s about y, plus 0.01 rad/s about x and z each, 34.4 °/s in total, for 5 s. The true angles (quat_to_euler; scipy via as_euler gave the same numbers):

t, s roll, ° pitch, ° heading, °
2.50 24.46 85.55 54.46
2.55 38.77 87.01 68.77
2.60 72.21 88.01 102.21
2.65 119.92 87.78 149.92
2.70 145.33 86.56 175.33
3.00 170.84 76.69 −159.16

In half a second the device turned by 17°, while roll and heading turned by 146°. If you integrate the angles themselves, the derivatives contain tan and 1/cos of pitch: the rate of roll or heading reaches 1303 °/s with a rotation of 34.4 °/s, and the error over the experiment is 0.79°. The quaternion went the same way with an error of 0.0438° — that is float rounding. Conclusion: compute and store the quaternion, and use the angles for display only.


99б.3 The Madgwick filter: a step against the gradient

At rest the accelerometer sees the Earth's "up" (0, 0, 1) in the sensor axes. With the current q it should see Rᵀ(q)·(0, 0, 1); the difference from the normalized measurement is the error f. The same goes for the magnetometer: the field in the Earth axes is (bx, 0, bz), with the horizontal part pointing strictly north. The filter takes the gradient ∇ = Jᵀ·f (J is the derivatives of f with respect to w, x, y, z) and adds to the gyroscope step a step against it of length β:

q̇ = ½·q ⊗ (0, ω) − β·∇/|∇|, then q ← norm(q + q̇·dt).

β is in radians per second: how fast the correction can rotate the estimate. The reference field (bx, bz) is taken from the measurement itself: the measured vector is moved into the Earth axes, its horizontal part is folded onto north, and the vertical part stays. So you do not need to know the inclination.

/* Вектор из осей датчика в оси Земли: out = R(q) v. */
void quat_rotate(quat_t q, const float v[3], float out[3])
{
    float xx = q.x * q.x, yy = q.y * q.y, zz = q.z * q.z;
    float xy = q.x * q.y, xz = q.x * q.z, yz = q.y * q.z;
    float wx = q.w * q.x, wy = q.w * q.y, wz = q.w * q.z;
    out[0] = 2.0f * ((0.5f - yy - zz) * v[0] + (xy - wz) * v[1] + (xz + wy) * v[2]);
    out[1] = 2.0f * ((xy + wz) * v[0] + (0.5f - xx - zz) * v[1] + (yz - wx) * v[2]);
    out[2] = 2.0f * ((xz - wy) * v[0] + (yz + wx) * v[1] + (0.5f - xx - yy) * v[2]);
}

/* Обратно: из осей Земли в оси датчика, out = R(q)^T v. */
void quat_rotate_inv(quat_t q, const float v[3], float out[3])
{
    float xx = q.x * q.x, yy = q.y * q.y, zz = q.z * q.z;
    float xy = q.x * q.y, xz = q.x * q.z, yz = q.y * q.z;
    float wx = q.w * q.x, wy = q.w * q.y, wz = q.w * q.z;
    out[0] = 2.0f * ((0.5f - yy - zz) * v[0] + (xy + wz) * v[1] + (xz - wy) * v[2]);
    out[1] = 2.0f * ((xy - wz) * v[0] + (0.5f - xx - zz) * v[1] + (yz + wx) * v[2]);
    out[2] = 2.0f * ((xz + wy) * v[0] + (yz - wx) * v[1] + (0.5f - xx - yy) * v[2]);
}
quat_t madgwick_step(quat_t q, const float g[3], const float a[3], const float m[3], float beta, float dt)
{
    quat_t w = { 0.0f, g[0], g[1], g[2] };
    quat_t d = quat_mul(q, w); /* 2 · dq/dt по гироскопу */
    float s0 = 0.0f, s1 = 0.0f, s2 = 0.0f, s3 = 0.0f; /* градиент по w, x, y, z */
    float an = a ? a[0] * a[0] + a[1] * a[1] + a[2] * a[2] : 0.0f;

    if (an > 0.0f) {
        float k = 1.0f / sqrtf(an);
        /* где «верх» должен быть в осях датчика при этом q, минус где его видит акселерометр */
        float f0 = 2.0f * (q.x * q.z - q.w * q.y) - a[0] * k;
        float f1 = 2.0f * (q.w * q.x + q.y * q.z) - a[1] * k;
        float f2 = 1.0f - 2.0f * (q.x * q.x + q.y * q.y) - a[2] * k;
        s0 += -2.0f * q.y * f0 + 2.0f * q.x * f1;
        s1 += 2.0f * q.z * f0 + 2.0f * q.w * f1 - 4.0f * q.x * f2;
        s2 += -2.0f * q.w * f0 + 2.0f * q.z * f1 - 4.0f * q.y * f2;
        s3 += 2.0f * q.x * f0 + 2.0f * q.y * f1;

        float mn = m ? m[0] * m[0] + m[1] * m[1] + m[2] * m[2] : 0.0f;
        if (mn > 0.0f) {
            float mv[3], h[3], r[3], b[3];
            k = 1.0f / sqrtf(mn);
            mv[0] = m[0] * k;
            mv[1] = m[1] * k;
            mv[2] = m[2] * k;
            quat_rotate(q, mv, h); /* измеренное поле в осях Земли */
            b[0] = sqrtf(h[0] * h[0] + h[1] * h[1]); /* горизонталь — строго на север */
            b[1] = 0.0f;
            b[2] = h[2]; /* наклонение берётся из самого измерения */
            quat_rotate_inv(q, b, r); /* где поле должно быть в осях датчика */
            float e0 = r[0] - mv[0], e1 = r[1] - mv[1], e2 = r[2] - mv[2];
            float bx = 2.0f * b[0], bz = 2.0f * b[2];
            s0 += -bz * q.y * e0 + (-bx * q.z + bz * q.x) * e1 + bx * q.y * e2;
            s1 += bz * q.z * e0 + (bx * q.y + bz * q.w) * e1 + (bx * q.z - 2.0f * bz * q.x) * e2;
            s2 += (-2.0f * bx * q.y - bz * q.w) * e0 + (bx * q.x + bz * q.z) * e1 + (bx * q.w - 2.0f * bz * q.y) * e2;
            s3 += (-2.0f * bx * q.z + bz * q.x) * e0 + (-bx * q.w + bz * q.y) * e1 + bx * q.x * e2;
        }
    }

    float sn = s0 * s0 + s1 * s1 + s2 * s2 + s3 * s3;
    float k = sn > 0.0f ? beta / sqrtf(sn) : 0.0f; /* шаг длиной beta против градиента */
    q.w += (0.5f * d.w - k * s0) * dt;
    q.x += (0.5f * d.x - k * s1) * dt;
    q.y += (0.5f * d.y - k * s2) * dt;
    q.z += (0.5f * d.z - k * s3) * dt;
    return quat_norm(q);
}

The derivatives were written out by hand. numpy checked them a different way: the gradient was taken numerically, as a central difference of ½·|f|², and the whole filter ran in float64 on the same samples. Over 12000 steps the estimates differed by no more than 0.002°, and the tilt and heading errors matched to the hundredths.

Why Madgwick and not Mahony. Betaflight (imu.c) computes orientation with the Mahony filter (imuMahonyAHRSupdate): the error is the cross product of the measured and expected "up", and the correction is added to the gyroscope proportionally (by default Kp = 0.25: imu_dcm_kp = 2500 divided by 10000) and integrally (Ki = 0 — disabled). The integral part of Mahony finds the bias by itself. Madgwick has one parameter with a clear meaning and gravity and field in one function; here the bias is estimated separately, during pauses (99б.4), — this is easy to check. The price is in 99б.5: with Madgwick a magnetometer error also pulls the tilt. Betaflight applies the magnetometer correction only about the Earth's vertical.

Choosing β (with the magnetometer, ellipsoid, pauses and rejection enabled). Convergence — the device lies with 20° roll, −10° pitch and 60° heading, the filter starts from zero rotation and without a boost; motion — the model from 99б.1 with a boost of β = 1 for the first 3 s:

β, rad/s convergence to 2° tilt RMS / max, ° heading RMS / max, ° without bias estimation: tilt / heading RMS, °
0.01 not within 60 s 0.32 / 0.74 0.46 / 3.47 0.86 / 4.25
0.033 25.41 s 0.37 / 1.03 0.41 / 3.76 0.52 / 0.96
0.1 8.38 s 0.55 / 3.04 0.44 / 4.56 0.51 / 0.56
0.3 2.82 s 0.85 / 5.93 0.53 / 6.20 0.82 / 0.55
1 1.03 s 1.72 / 14.23 0.82 / 8.23 1.73 / 0.83

A small β is quiet but slow, and without bias estimation it drifts away; a large one converges fast but lets jolts and noise into the estimate. The working point is β = 0.033 with a boost of β = 1 for the first 3 s: from zero rotation to 2° in 0.49 s.


99б.4 Pauses and jolts: gyroscope bias and acceleration rejection

In section 99.6 the bias was measured once at power-up. But it drifts with temperature, and the device often lies still: every pause — 100 samples in a row where |a| differs from 1 g by less than 0.03 g and the raw gyroscope reads less than 2 °/s — gives a fresh estimate. Rejection: if |a| differs from 1 g by more than 0.1 g, the accelerometer sees more than just gravity, and the step uses the gyroscope alone.

/* ahrs99b.h — раздел 99б: фильтр Маджвика (градиентный шаг) с отбрасыванием ускорений
 * и оценкой смещения нуля гироскопа по паузам. Гироскоп — рад/с, акселерометр — в g,
 * магнитометр — в любых единицах (нормируется), после калибровки (magcal99b.h). */
#ifndef AHRS99B_H
#define AHRS99B_H

#include "quat99b.h"

#define REST_SAMPLES 100 /* пауза: столько отсчётов подряд без движения */

typedef struct {
    quat_t q;
    float beta;          /* шаг коррекции, рад/с */
    float acc_tol;       /* допуск |a| около 1 g для коррекции; 0 — не отбрасывать */
    int use_rest;        /* 1 — оценивать смещение нуля гироскопа по паузам */
    float bias[3];       /* оценка смещения, рад/с */
    float sum[3];
    int n_rest;
    unsigned rejected;   /* сколько шагов прошло без коррекции */
    unsigned rest_updates;
} ahrs_t;

quat_t madgwick_step(quat_t q, const float g[3], const float a[3], const float m[3], float beta, float dt);
void ahrs_init(ahrs_t *s, float beta, float acc_tol, int use_rest);
void ahrs_update(ahrs_t *s, const float g[3], const float a[3], const float m[3], float dt);

#endif
#define REST_ACC 0.03f   /* пауза: |a| отличается от 1 g меньше чем на 0,03 g */
#define REST_GYRO 0.035f /* и сырой гироскоп меньше 0,035 рад/с (2 °/с) */
void ahrs_init(ahrs_t *s, float beta, float acc_tol, int use_rest)
{
    quat_t one = { 1.0f, 0.0f, 0.0f, 0.0f };
    s->q = one;
    s->beta = beta;
    s->acc_tol = acc_tol;
    s->use_rest = use_rest;
    for (int i = 0; i < 3; i++)
        s->bias[i] = s->sum[i] = 0.0f;
    s->n_rest = 0;
    s->rejected = 0u;
    s->rest_updates = 0u;
}

void ahrs_update(ahrs_t *s, const float g[3], const float a[3], const float m[3], float dt)
{
    float gc[3];
    float an = sqrtf(a[0] * a[0] + a[1] * a[1] + a[2] * a[2]);

    if (s->use_rest) {
        /* пауза: прибор лежит — среднее сырого гироскопа за REST_SAMPLES отсчётов и есть смещение */
        float gn = g[0] * g[0] + g[1] * g[1] + g[2] * g[2];
        if (fabsf(an - 1.0f) < REST_ACC && gn < REST_GYRO * REST_GYRO) {
            for (int i = 0; i < 3; i++)
                s->sum[i] += g[i];
            if (++s->n_rest == REST_SAMPLES) {
                for (int i = 0; i < 3; i++) {
                    s->bias[i] = s->sum[i] / REST_SAMPLES;
                    s->sum[i] = 0.0f;
                }
                s->n_rest = 0;
                s->rest_updates++;
            }
        } else {
            s->n_rest = 0;
            s->sum[0] = s->sum[1] = s->sum[2] = 0.0f;
        }
    }
    for (int i = 0; i < 3; i++)
        gc[i] = g[i] - s->bias[i];

    if (s->acc_tol > 0.0f && fabsf(an - 1.0f) > s->acc_tol) {
        /* ускорение: акселерометр показывает не только тяжесть — шаг только по гироскопу */
        s->rejected++;
        s->q = madgwick_step(s->q, gc, 0, 0, s->beta, dt);
    } else {
        s->q = madgwick_step(s->q, gc, a, m, s->beta, dt);
    }
}

Over the run the bias was updated 40 times; at the end the estimate is 0.974; 0.056; −0.207 °/s against the true 0.98; 0.06; −0.20. During shaking 212 steps went without correction. What each part gives (β = 0.033, magnetometer calibrated with the ellipsoid, 99б.5):

variant tilt RMS / max, ° heading RMS / max, ° tilt during shaking, max, °
gyroscope only 18.25 / 36.35 16.98 / 179.65 20.21
Madgwick without magnetometer 0.37 / 1.03 1.30 / 3.21 1.03
with magnetometer, everything enabled 0.37 / 1.03 0.41 / 3.76 1.03
without acceleration rejection 0.44 / 1.78 0.41 / 3.76 1.78
without bias estimation 0.52 / 2.20 0.96 / 3.60 2.20

Without the magnetometer the filter started from the truth (it has nowhere to get heading from), and after the first 10 s the heading differed from the truth by no more than 3.21°: the estimation during pauses also removes the bias on the z axis. But without a magnetometer there is nothing to bring the heading back. Betaflight has the same rejection rule: the accelerometer is accepted only if |a| is between 0.9 and 1.1 g (imuIsAccelerometerHealthy).


99б.5 The magnetometer: hard iron, soft iron, ellipsoid to sphere

Without interference, the tip of the field vector lies on a sphere for any rotation. Near magnets and board currents — hard iron — the sphere shifts by V. Steel nearby — soft iron — stretches and tilts it into an ellipsoid: m = A·m_true + V. Calibration finds V and W ≈ A⁻¹ and computes m_cal = W·(m − V). The rotational part of A cannot be seen from the points, nor can the scale — but the filter needs only directions.

The simplest way is the middle of the range on each axis: it removes V but not A. The full way is to fit the surface a·x² + b·y² + c·z² + 2f·yz + 2g·xz + 2h·xy + 2p·x + 2q·y + 2r·z = 1 by least squares (nine unknowns), then from the matrix M = [[a, h, g], [h, b, f], [g, f, c]] find the centre u0 = −M⁻¹·(p, q, r) and W = E·diag(√(λ/k))·Eᵀ, where λ and E are the eigenvalues and eigenvectors of M, and k = 1 + u0ᵀ·M·u0. So that float is enough, the points are first shifted by the range centre and divided by the radius: the numbers are near one. The normal equations are accumulated one point at a time — 360 bytes for the whole calibration.

/* magcal99b.h — раздел 99б: калибровка магнитометра.
 * Модель искажения: m = A · m_истинное + V. V — жёсткое железо (сдвиг центра), A — мягкое
 * железо (сфера становится эллипсоидом). Калибровка находит V и W ≈ A^-1 с точностью до
 * масштаба и поворота: m_кал = W · (m − V) лежит на сфере радиуса 1. */
#ifndef MAGCAL99B_H
#define MAGCAL99B_H

typedef struct {
    float c[3];      /* предварительный центр (по минимуму и максимуму) */
    float scale;     /* 1 / предварительный радиус: числа около единицы, float хватает */
    float n[9][10];  /* нормальные уравнения D^T D | D^T 1, копятся по одному отсчёту */
    unsigned count;
} magfit_t;

void mag_minmax(const float (*m)[3], unsigned n, float v[3], float *radius);
void magfit_init(magfit_t *f, const float c[3], float radius);
void magfit_add(magfit_t *f, const float m[3]);
int magfit_solve(magfit_t *f, float v[3], float w[3][3], float *pivot_ratio);
void sym3_eig(float a[3][3], float vec[3][3]);
void magcal_apply(const float v[3], const float w[3][3], const float m[3], float out[3]);

#endif
void mag_minmax(const float (*m)[3], unsigned n, float v[3], float *radius)
{
    float lo[3], hi[3], r = 0.0f;
    for (int k = 0; k < 3; k++)
        lo[k] = hi[k] = m[0][k];
    for (unsigned i = 1; i < n; i++)
        for (int k = 0; k < 3; k++) {
            if (m[i][k] < lo[k])
                lo[k] = m[i][k];
            if (m[i][k] > hi[k])
                hi[k] = m[i][k];
        }
    for (int k = 0; k < 3; k++) {
        v[k] = 0.5f * (lo[k] + hi[k]); /* жёсткое железо: середина размаха по каждой оси */
        r += 0.5f * (hi[k] - lo[k]);
    }
    *radius = r / 3.0f;
}

void magfit_init(magfit_t *f, const float c[3], float radius)
{
    for (int k = 0; k < 3; k++)
        f->c[k] = c[k];
    f->scale = 1.0f / radius;
    for (int i = 0; i < 9; i++)
        for (int j = 0; j < 10; j++)
            f->n[i][j] = 0.0f;
    f->count = 0u;
}

/* Один отсчёт: строка d плана и прибавка d·d^T и d к нормальным уравнениям. Память — 360 байт
 * на всю калибровку, сколько бы отсчётов ни пришло. */
void magfit_add(magfit_t *f, const float m[3])
{
    float x = (m[0] - f->c[0]) * f->scale;
    float y = (m[1] - f->c[1]) * f->scale;
    float z = (m[2] - f->c[2]) * f->scale;
    float d[9] = { x * x, y * y, z * z, 2.0f * y * z, 2.0f * x * z, 2.0f * x * y, 2.0f * x, 2.0f * y, 2.0f * z };
    for (int i = 0; i < 9; i++) {
        for (int j = i; j < 9; j++)
            f->n[i][j] += d[i] * d[j];
        f->n[i][9] += d[i];
    }
    f->count++;
}
/* Собственные числа и векторы симметричной 3×3 вращениями Якоби: на выходе a — диагональ
 * собственных чисел, столбцы vec — собственные векторы. */
void sym3_eig(float a[3][3], float vec[3][3])
{
    for (int i = 0; i < 3; i++)
        for (int j = 0; j < 3; j++)
            vec[i][j] = i == j ? 1.0f : 0.0f;
    for (int sweep = 0; sweep < 8; sweep++)
        for (int p = 0; p < 2; p++)
            for (int q = p + 1; q < 3; q++) {
                float apq = a[p][q];
                if (fabsf(apq) <= 1e-9f * (fabsf(a[p][p]) + fabsf(a[q][q]))) {
                    a[p][q] = a[q][p] = 0.0f;
                    continue;
                }
                float th = (a[q][q] - a[p][p]) / (2.0f * apq);
                float t = (th >= 0.0f ? 1.0f : -1.0f) / (fabsf(th) + sqrtf(th * th + 1.0f));
                float c = 1.0f / sqrtf(t * t + 1.0f), s = t * c;
                for (int k = 0; k < 3; k++) {
                    float kp = a[k][p], kq = a[k][q];
                    a[k][p] = c * kp - s * kq;
                    a[k][q] = s * kp + c * kq;
                }
                for (int k = 0; k < 3; k++) {
                    float pk = a[p][k], qk = a[q][k];
                    a[p][k] = c * pk - s * qk;
                    a[q][k] = s * pk + c * qk;
                }
                for (int k = 0; k < 3; k++) {
                    float kp = vec[k][p], kq = vec[k][q];
                    vec[k][p] = c * kp - s * kq;
                    vec[k][q] = s * kp + c * kq;
                }
            }
}
/* Решить нормальные уравнения и превратить эллипсоид в центр v и матрицу w.
 * 0 — готово; -1 — система вырождена (точки не покрывают все стороны); -2 — это не эллипсоид. */
int magfit_solve(magfit_t *f, float v[3], float w[3][3], float *pivot_ratio)
{
    float a[9][10], sol[9], pmin = INFINITY, pmax = 0.0f;
    for (int i = 0; i < 9; i++)
        for (int j = 0; j < 10; j++)
            a[i][j] = j >= i ? f->n[i][j] : f->n[j][i]; /* копилась только верхняя половина */

    for (int col = 0; col < 9; col++) { /* метод Гаусса с выбором главного элемента */
        int best = col;
        for (int i = col + 1; i < 9; i++)
            if (fabsf(a[i][col]) > fabsf(a[best][col]))
                best = i;
        for (int j = 0; j < 10; j++) {
            float t = a[col][j];
            a[col][j] = a[best][j];
            a[best][j] = t;
        }
        float piv = fabsf(a[col][col]);
        if (piv < pmin)
            pmin = piv;
        if (piv > pmax)
            pmax = piv;
        if (piv == 0.0f)
            return -1;
        for (int i = col + 1; i < 9; i++) {
            float k = a[i][col] / a[col][col];
            for (int j = col; j < 10; j++)
                a[i][j] -= k * a[col][j];
        }
    }
    for (int i = 8; i >= 0; i--) {
        float s = a[i][9];
        for (int j = i + 1; j < 9; j++)
            s -= a[i][j] * sol[j];
        sol[i] = s / a[i][i];
    }
    *pivot_ratio = pmin / pmax;
    if (*pivot_ratio < 1e-3f)
        return -1;

    float mq[3][3] = { { sol[0], sol[5], sol[4] }, { sol[5], sol[1], sol[3] }, { sol[4], sol[3], sol[2] } };
    float m0[3][3], e[3][3], u0[3], pr[3], kk;
    for (int i = 0; i < 3; i++)
        for (int j = 0; j < 3; j++)
            m0[i][j] = mq[i][j];
    sym3_eig(mq, e); /* M = E · diag(l) · E^T */
    for (int i = 0; i < 3; i++)
        if (!(mq[i][i] > 0.0f))
            return -2;
    for (int i = 0; i < 3; i++) /* центр: u0 = −M^-1 · (p, q, r) */
        pr[i] = (e[0][i] * sol[6] + e[1][i] * sol[7] + e[2][i] * sol[8]) / mq[i][i];
    for (int i = 0; i < 3; i++)
        u0[i] = -(e[i][0] * pr[0] + e[i][1] * pr[1] + e[i][2] * pr[2]);
    kk = 1.0f; /* (u−u0)^T M (u−u0) = 1 + u0^T M u0 */
    for (int i = 0; i < 3; i++)
        for (int j = 0; j < 3; j++)
            kk += u0[i] * m0[i][j] * u0[j];
    if (!(kk > 0.0f))
        return -2;
    for (int i = 0; i < 3; i++) {
        v[i] = f->c[i] + u0[i] / f->scale;
        for (int j = 0; j < 3; j++) { /* w = E · diag(sqrt(l / kk)) · E^T, в единицах датчика */
            float s = 0.0f;
            for (int k = 0; k < 3; k++)
                s += e[i][k] * sqrtf(mq[k][k] / kk) * e[j][k];
            w[i][j] = s * f->scale;
        }
    }
    return 0;
}

void magcal_apply(const float v[3], const float w[3][3], const float m[3], float out[3])
{
    float d[3] = { m[0] - v[0], m[1] - v[1], m[2] - v[2] };
    for (int i = 0; i < 3; i++)
        out[i] = w[i][0] * d[0] + w[i][1] * d[1] + w[i][2] * d[2];
}

Experiment: three sets of 600 points — the device is turned in all directions; tilted by no more than 30°; rotated on a table (tilt with a spread of 1°). The spread of the field length (largest minus smallest, relative to the mean) is on the first set; compass is the heading from the calibrated field against the true one on a level device, 360 positions, no noise:

calibration centre V, µT length spread compass, max
none — 142.3 % 179.17°
hard iron (middle of range) 24.82; −11.88; 40.04 31.3 % 18.51°
ellipsoid, all directions 24.98; −11.97; 39.99 3.38 % 0.12°
ellipsoid, tilt up to 30° 25.05; −12.23; 38.57 8.24 % 0.38°
ellipsoid, on a table fails (code −1) — —

On the first set W·A = 0.01999·(I + 0.0008): the inverse matrix was found to within 0.08 %. numpy (lstsq and eigh in float64 on the raw points) gave the same centre to within 0.0001 µT and W to within 8.21·10⁻⁷; the rest is noise: the length spread after calibration is 0.566 % RMS, and the noise of 0.3 µT out of 50 is 0.6 %. On the "up to 30°" set, float and double already differ by 0.26 µT in z (38.57 against 38.31). On the table, the ratio of the smallest Gaussian elimination pivot to the largest is 6.15·10⁻⁴ against 6.14·10⁻² on the full set, and magfit_solve refuses (threshold 10⁻³). numpy without the threshold gave a z centre of −4.64 µT instead of 40: on a table z barely changes, and its centre cannot be found from such points.

With the filter (β = 0.033, pauses and rejection enabled):

magnetometer tilt RMS / max, ° heading RMS / max, °
no calibration 8.60 / 18.13 43.06 / 177.68
hard iron only 11.63 / 25.58 42.39 / 110.95
ellipsoid 0.37 / 1.03 0.41 / 3.76

Two things. An uncalibrated magnetometer spoils not only the heading but also the tilt: 8.60° against 0.37° — the Madgwick gradient rotates the estimate about any axis, not only the vertical. And hard iron alone is not enough: on a level device the compass is off by 18.51°, and in motion the heading drifts by tens of degrees. With 70° inclination the horizontal field is only 17.1 µT out of 50, and a small error in the vector direction becomes a large heading error.


99б.6 On the microcontroller: size, instructions per step, M3 without an FPU and M4F

The code is built with arm-none-eabi-gcc 12.2.1 with -Os for Cortex-M3 (like the F103: no FPU, float is computed by libgcc) and Cortex-M4F (like the F407: -mfpu=fpv4-sp-d16 -mfloat-abi=hard). Code size, bytes:

file Cortex-M3 Cortex-M4F
quat99b.o 1872 950
ahrs99b.o 2008 1226
magcal99b.o 2184 1804

In ahrs99b.o on the M3 there are 175 calls to the float library; on the M4F there are 90 FPU instructions instead. The firmware "sensors → calibration → filter" (size99b.c, unused code removed by the linker): on the M3 — 5236 bytes of code, of which 1752 are libraries (float addition, multiplication, division and comparison from libgcc, sqrtf from libm), plus 104 bytes of data: the library sqrtf writes errno, and that brings in a newlib housekeeping structure. On the M4F — 2032 bytes, zero library code: the square root is a single FPU instruction.

Instructions per call — QEMU (netduino2 with a Cortex-M3 core and netduinoplus2 with a Cortex-M4F) in -icount mode, SysTick counts, calibrated with a loop of two instructions:

function Cortex-M3 Cortex-M4F
quat_gyro_step 2410 77
madgwick_step, accelerometer only 6122 165
madgwick_step, with magnetometer 12826 352
ahrs_update, whole 13900 420
quat_to_euler 5171 315
magcal_apply 766 53
magfit_add, per point 5436 481
magfit_solve, once 71608 7012

These are instructions, not cycles: there are more cycles on the chip, so the times below are a lower bound. An ahrs_update step on the M3 at 72 MHz takes at least 193 µs: at 100 Hz that is at least 1.9 % of the CPU, at 1 kHz — 19 %. On the M4F at 168 MHz — at least 2.5 µs, 33 times fewer instructions. Calibration on 600 points on the M3 is about 3.33 million instructions, at least 46 ms: it is done once. The fit on the Cortex-M itself, on points of an exact ellipsoid, gave the centre 25.00; −12.00; 40.00 and semi-axes 57; 46; 52 — the same on the M3 and the M4F, and the resulting quaternions matched to the last printed digit.


99б.7 Pitfalls

The heading jumps by 180° although the device turned by a few degrees. Pitch near ±90°: gimbal lock. Roll and heading are not defined separately there (99б.2). Store the quaternion, use the angles for display only; for control use the quaternion itself or the "up" and "north" vectors.

Without the magnetometer the horizon holds, with it the horizon tips over. The magnetometer is not calibrated or there is iron nearby: with Madgwick a field error also pulls the tilt — 8.60° RMS against 0.37°. Calibrate; if the interference cannot be removed, apply the magnetometer correction only about the vertical, as in Betaflight.

Calibrated on a table — on the table the compass is accurate, in the hand it lies. On a table z barely changes, and the z centre cannot be found: numpy without the check gave −4.64 µT instead of 40. magfit_solve refuses in this case (pivot ratio 6.15·10⁻⁴). Turn the device in all directions.

Took only hard iron into account — the heading still drifts. The middle of the range removes the offset but not the ellipsoid: 18.51° on a level device, in motion — tens of degrees. You need the full fit.

The filter takes half a minute to "wake up". β is small: from zero rotation to 2° in 25.41 s with β = 0.033. A boost at start: β = 1 for the first 3 s — 0.49 s. Betaflight does the same: while the motors are not running, Kp is 10 times larger, and after the motors are disarmed, having waited for 250 ms of rest, it is 100 times larger for 0.5 s.

The horizon twitches on jolts. β is large or there is no rejection: with β = 1 the tilt goes up to 14.23°, without rejection during shaking — 1.78° against 1.03°.

With a small β the heading slowly drifts. Gyroscope bias: with β = 0.01 without estimation during pauses the heading is 4.25° RMS against 0.46°. If there are no pauses in operation, you have to take a larger β.

The quaternion "swelled", the angles went off. The length is not 1. Normalize after every step (quat_norm).

On a Cortex-M3 the filter eats a noticeable share of the CPU. Software float: 13900 instructions per step against 420 on the M4F. On the M3 — use a lower update rate or a core with an FPU.


99б.8 Summary

Full source code

quat99b.c — quaternion operations and gyroscope integration.

/* quat99b.c — раздел 99б: действия с кватернионом и интегрирование гироскопа. */
#include <math.h>
#include "quat99b.h"

/* Произведение a ⊗ b: сначала поворот b, потом a. */
quat_t quat_mul(quat_t a, quat_t b)
{
    quat_t r;
    r.w = a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z;
    r.x = a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y;
    r.y = a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x;
    r.z = a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w;
    return r;
}

/* Привести к длине 1: ошибки округления копятся, и без этого кватернион «распухает». */
quat_t quat_norm(quat_t q)
{
    float n = 1.0f / sqrtf(q.w * q.w + q.x * q.x + q.y * q.y + q.z * q.z);
    q.w *= n;
    q.x *= n;
    q.y *= n;
    q.z *= n;
    return q;
}

/* Вектор из осей датчика в оси Земли: out = R(q) v. */
void quat_rotate(quat_t q, const float v[3], float out[3])
{
    float xx = q.x * q.x, yy = q.y * q.y, zz = q.z * q.z;
    float xy = q.x * q.y, xz = q.x * q.z, yz = q.y * q.z;
    float wx = q.w * q.x, wy = q.w * q.y, wz = q.w * q.z;
    out[0] = 2.0f * ((0.5f - yy - zz) * v[0] + (xy - wz) * v[1] + (xz + wy) * v[2]);
    out[1] = 2.0f * ((xy + wz) * v[0] + (0.5f - xx - zz) * v[1] + (yz - wx) * v[2]);
    out[2] = 2.0f * ((xz - wy) * v[0] + (yz + wx) * v[1] + (0.5f - xx - yy) * v[2]);
}

/* Обратно: из осей Земли в оси датчика, out = R(q)^T v. */
void quat_rotate_inv(quat_t q, const float v[3], float out[3])
{
    float xx = q.x * q.x, yy = q.y * q.y, zz = q.z * q.z;
    float xy = q.x * q.y, xz = q.x * q.z, yz = q.y * q.z;
    float wx = q.w * q.x, wy = q.w * q.y, wz = q.w * q.z;
    out[0] = 2.0f * ((0.5f - yy - zz) * v[0] + (xy + wz) * v[1] + (xz - wy) * v[2]);
    out[1] = 2.0f * ((xy - wz) * v[0] + (0.5f - xx - zz) * v[1] + (yz + wx) * v[2]);
    out[2] = 2.0f * ((xz + wy) * v[0] + (yz - wx) * v[1] + (0.5f - xx - yy) * v[2]);
}

/* Шаг по гироскопу: dq/dt = 0,5 · q ⊗ (0, ω), ω в рад/с в осях датчика. */
quat_t quat_gyro_step(quat_t q, const float g[3], float dt)
{
    quat_t w = { 0.0f, g[0], g[1], g[2] };
    quat_t d = quat_mul(q, w);
    float h = 0.5f * dt;
    q.w += d.w * h;
    q.x += d.x * h;
    q.y += d.y * h;
    q.z += d.z * h;
    return quat_norm(q);
}

/* Углы Эйлера в порядке «курс, тангаж, крен» (Z, Y, X), радианы.
 * При тангаже ±90° крен и курс слиты в один поворот — это и есть «замок». */
void quat_to_euler(quat_t q, float *roll, float *pitch, float *yaw)
{
    float s = 2.0f * (q.w * q.y - q.z * q.x);
    if (s > 1.0f)
        s = 1.0f;
    if (s < -1.0f)
        s = -1.0f;
    *roll = atan2f(2.0f * (q.w * q.x + q.y * q.z), 1.0f - 2.0f * (q.x * q.x + q.y * q.y));
    *pitch = asinf(s);
    *yaw = atan2f(2.0f * (q.w * q.z + q.x * q.y), 1.0f - 2.0f * (q.y * q.y + q.z * q.z));
}

quat_t quat_from_euler(float roll, float pitch, float yaw)
{
    float cr = cosf(0.5f * roll), sr = sinf(0.5f * roll);
    float cp = cosf(0.5f * pitch), sp = sinf(0.5f * pitch);
    float cy = cosf(0.5f * yaw), sy = sinf(0.5f * yaw);
    quat_t q;
    q.w = cr * cp * cy + sr * sp * sy;
    q.x = sr * cp * cy - cr * sp * sy;
    q.y = cr * sp * cy + sr * cp * sy;
    q.z = cr * cp * sy - sr * sp * cy;
    return q;
}

ahrs99b.c — the Madgwick filter.

/* ahrs99b.c — раздел 99б: фильтр Маджвика. Шаг гироскопа плюс шаг против градиента ошибки:
 * ошибка — разница между тем, где тяжесть и магнитное поле должны быть при текущем q, и где их
 * показывают датчики. Длина шага коррекции — beta (рад/с), направление — по градиенту. */
#include <math.h>
#include "ahrs99b.h"

#define REST_ACC 0.03f   /* пауза: |a| отличается от 1 g меньше чем на 0,03 g */
#define REST_GYRO 0.035f /* и сырой гироскоп меньше 0,035 рад/с (2 °/с) */

quat_t madgwick_step(quat_t q, const float g[3], const float a[3], const float m[3], float beta, float dt)
{
    quat_t w = { 0.0f, g[0], g[1], g[2] };
    quat_t d = quat_mul(q, w); /* 2 · dq/dt по гироскопу */
    float s0 = 0.0f, s1 = 0.0f, s2 = 0.0f, s3 = 0.0f; /* градиент по w, x, y, z */
    float an = a ? a[0] * a[0] + a[1] * a[1] + a[2] * a[2] : 0.0f;

    if (an > 0.0f) {
        float k = 1.0f / sqrtf(an);
        /* где «верх» должен быть в осях датчика при этом q, минус где его видит акселерометр */
        float f0 = 2.0f * (q.x * q.z - q.w * q.y) - a[0] * k;
        float f1 = 2.0f * (q.w * q.x + q.y * q.z) - a[1] * k;
        float f2 = 1.0f - 2.0f * (q.x * q.x + q.y * q.y) - a[2] * k;
        s0 += -2.0f * q.y * f0 + 2.0f * q.x * f1;
        s1 += 2.0f * q.z * f0 + 2.0f * q.w * f1 - 4.0f * q.x * f2;
        s2 += -2.0f * q.w * f0 + 2.0f * q.z * f1 - 4.0f * q.y * f2;
        s3 += 2.0f * q.x * f0 + 2.0f * q.y * f1;

        float mn = m ? m[0] * m[0] + m[1] * m[1] + m[2] * m[2] : 0.0f;
        if (mn > 0.0f) {
            float mv[3], h[3], r[3], b[3];
            k = 1.0f / sqrtf(mn);
            mv[0] = m[0] * k;
            mv[1] = m[1] * k;
            mv[2] = m[2] * k;
            quat_rotate(q, mv, h); /* измеренное поле в осях Земли */
            b[0] = sqrtf(h[0] * h[0] + h[1] * h[1]); /* горизонталь — строго на север */
            b[1] = 0.0f;
            b[2] = h[2]; /* наклонение берётся из самого измерения */
            quat_rotate_inv(q, b, r); /* где поле должно быть в осях датчика */
            float e0 = r[0] - mv[0], e1 = r[1] - mv[1], e2 = r[2] - mv[2];
            float bx = 2.0f * b[0], bz = 2.0f * b[2];
            s0 += -bz * q.y * e0 + (-bx * q.z + bz * q.x) * e1 + bx * q.y * e2;
            s1 += bz * q.z * e0 + (bx * q.y + bz * q.w) * e1 + (bx * q.z - 2.0f * bz * q.x) * e2;
            s2 += (-2.0f * bx * q.y - bz * q.w) * e0 + (bx * q.x + bz * q.z) * e1 + (bx * q.w - 2.0f * bz * q.y) * e2;
            s3 += (-2.0f * bx * q.z + bz * q.x) * e0 + (-bx * q.w + bz * q.y) * e1 + bx * q.x * e2;
        }
    }

    float sn = s0 * s0 + s1 * s1 + s2 * s2 + s3 * s3;
    float k = sn > 0.0f ? beta / sqrtf(sn) : 0.0f; /* шаг длиной beta против градиента */
    q.w += (0.5f * d.w - k * s0) * dt;
    q.x += (0.5f * d.x - k * s1) * dt;
    q.y += (0.5f * d.y - k * s2) * dt;
    q.z += (0.5f * d.z - k * s3) * dt;
    return quat_norm(q);
}

void ahrs_init(ahrs_t *s, float beta, float acc_tol, int use_rest)
{
    quat_t one = { 1.0f, 0.0f, 0.0f, 0.0f };
    s->q = one;
    s->beta = beta;
    s->acc_tol = acc_tol;
    s->use_rest = use_rest;
    for (int i = 0; i < 3; i++)
        s->bias[i] = s->sum[i] = 0.0f;
    s->n_rest = 0;
    s->rejected = 0u;
    s->rest_updates = 0u;
}

void ahrs_update(ahrs_t *s, const float g[3], const float a[3], const float m[3], float dt)
{
    float gc[3];
    float an = sqrtf(a[0] * a[0] + a[1] * a[1] + a[2] * a[2]);

    if (s->use_rest) {
        /* пауза: прибор лежит — среднее сырого гироскопа за REST_SAMPLES отсчётов и есть смещение */
        float gn = g[0] * g[0] + g[1] * g[1] + g[2] * g[2];
        if (fabsf(an - 1.0f) < REST_ACC && gn < REST_GYRO * REST_GYRO) {
            for (int i = 0; i < 3; i++)
                s->sum[i] += g[i];
            if (++s->n_rest == REST_SAMPLES) {
                for (int i = 0; i < 3; i++) {
                    s->bias[i] = s->sum[i] / REST_SAMPLES;
                    s->sum[i] = 0.0f;
                }
                s->n_rest = 0;
                s->rest_updates++;
            }
        } else {
            s->n_rest = 0;
            s->sum[0] = s->sum[1] = s->sum[2] = 0.0f;
        }
    }
    for (int i = 0; i < 3; i++)
        gc[i] = g[i] - s->bias[i];

    if (s->acc_tol > 0.0f && fabsf(an - 1.0f) > s->acc_tol) {
        /* ускорение: акселерометр показывает не только тяжесть — шаг только по гироскопу */
        s->rejected++;
        s->q = madgwick_step(s->q, gc, 0, 0, s->beta, dt);
    } else {
        s->q = madgwick_step(s->q, gc, a, m, s->beta, dt);
    }
}

magcal99b.c — magnetometer calibration: hard iron and ellipsoid.

/* magcal99b.c — раздел 99б: жёсткое железо по минимуму и максимуму и эллипсоид методом
 * наименьших квадратов. Поверхность второго порядка
 *   a·x² + b·y² + c·z² + 2f·yz + 2g·xz + 2h·xy + 2p·x + 2q·y + 2r·z = 1
 * подгоняется по всем отсчётам (9 неизвестных), потом из неё — центр и матрица в сферу. */
#include <math.h>
#include "magcal99b.h"

void mag_minmax(const float (*m)[3], unsigned n, float v[3], float *radius)
{
    float lo[3], hi[3], r = 0.0f;
    for (int k = 0; k < 3; k++)
        lo[k] = hi[k] = m[0][k];
    for (unsigned i = 1; i < n; i++)
        for (int k = 0; k < 3; k++) {
            if (m[i][k] < lo[k])
                lo[k] = m[i][k];
            if (m[i][k] > hi[k])
                hi[k] = m[i][k];
        }
    for (int k = 0; k < 3; k++) {
        v[k] = 0.5f * (lo[k] + hi[k]); /* жёсткое железо: середина размаха по каждой оси */
        r += 0.5f * (hi[k] - lo[k]);
    }
    *radius = r / 3.0f;
}

void magfit_init(magfit_t *f, const float c[3], float radius)
{
    for (int k = 0; k < 3; k++)
        f->c[k] = c[k];
    f->scale = 1.0f / radius;
    for (int i = 0; i < 9; i++)
        for (int j = 0; j < 10; j++)
            f->n[i][j] = 0.0f;
    f->count = 0u;
}

/* Один отсчёт: строка d плана и прибавка d·d^T и d к нормальным уравнениям. Память — 360 байт
 * на всю калибровку, сколько бы отсчётов ни пришло. */
void magfit_add(magfit_t *f, const float m[3])
{
    float x = (m[0] - f->c[0]) * f->scale;
    float y = (m[1] - f->c[1]) * f->scale;
    float z = (m[2] - f->c[2]) * f->scale;
    float d[9] = { x * x, y * y, z * z, 2.0f * y * z, 2.0f * x * z, 2.0f * x * y, 2.0f * x, 2.0f * y, 2.0f * z };
    for (int i = 0; i < 9; i++) {
        for (int j = i; j < 9; j++)
            f->n[i][j] += d[i] * d[j];
        f->n[i][9] += d[i];
    }
    f->count++;
}

/* Собственные числа и векторы симметричной 3×3 вращениями Якоби: на выходе a — диагональ
 * собственных чисел, столбцы vec — собственные векторы. */
void sym3_eig(float a[3][3], float vec[3][3])
{
    for (int i = 0; i < 3; i++)
        for (int j = 0; j < 3; j++)
            vec[i][j] = i == j ? 1.0f : 0.0f;
    for (int sweep = 0; sweep < 8; sweep++)
        for (int p = 0; p < 2; p++)
            for (int q = p + 1; q < 3; q++) {
                float apq = a[p][q];
                if (fabsf(apq) <= 1e-9f * (fabsf(a[p][p]) + fabsf(a[q][q]))) {
                    a[p][q] = a[q][p] = 0.0f;
                    continue;
                }
                float th = (a[q][q] - a[p][p]) / (2.0f * apq);
                float t = (th >= 0.0f ? 1.0f : -1.0f) / (fabsf(th) + sqrtf(th * th + 1.0f));
                float c = 1.0f / sqrtf(t * t + 1.0f), s = t * c;
                for (int k = 0; k < 3; k++) {
                    float kp = a[k][p], kq = a[k][q];
                    a[k][p] = c * kp - s * kq;
                    a[k][q] = s * kp + c * kq;
                }
                for (int k = 0; k < 3; k++) {
                    float pk = a[p][k], qk = a[q][k];
                    a[p][k] = c * pk - s * qk;
                    a[q][k] = s * pk + c * qk;
                }
                for (int k = 0; k < 3; k++) {
                    float kp = vec[k][p], kq = vec[k][q];
                    vec[k][p] = c * kp - s * kq;
                    vec[k][q] = s * kp + c * kq;
                }
            }
}

/* Решить нормальные уравнения и превратить эллипсоид в центр v и матрицу w.
 * 0 — готово; -1 — система вырождена (точки не покрывают все стороны); -2 — это не эллипсоид. */
int magfit_solve(magfit_t *f, float v[3], float w[3][3], float *pivot_ratio)
{
    float a[9][10], sol[9], pmin = INFINITY, pmax = 0.0f;
    for (int i = 0; i < 9; i++)
        for (int j = 0; j < 10; j++)
            a[i][j] = j >= i ? f->n[i][j] : f->n[j][i]; /* копилась только верхняя половина */

    for (int col = 0; col < 9; col++) { /* метод Гаусса с выбором главного элемента */
        int best = col;
        for (int i = col + 1; i < 9; i++)
            if (fabsf(a[i][col]) > fabsf(a[best][col]))
                best = i;
        for (int j = 0; j < 10; j++) {
            float t = a[col][j];
            a[col][j] = a[best][j];
            a[best][j] = t;
        }
        float piv = fabsf(a[col][col]);
        if (piv < pmin)
            pmin = piv;
        if (piv > pmax)
            pmax = piv;
        if (piv == 0.0f)
            return -1;
        for (int i = col + 1; i < 9; i++) {
            float k = a[i][col] / a[col][col];
            for (int j = col; j < 10; j++)
                a[i][j] -= k * a[col][j];
        }
    }
    for (int i = 8; i >= 0; i--) {
        float s = a[i][9];
        for (int j = i + 1; j < 9; j++)
            s -= a[i][j] * sol[j];
        sol[i] = s / a[i][i];
    }
    *pivot_ratio = pmin / pmax;
    if (*pivot_ratio < 1e-3f)
        return -1;

    float mq[3][3] = { { sol[0], sol[5], sol[4] }, { sol[5], sol[1], sol[3] }, { sol[4], sol[3], sol[2] } };
    float m0[3][3], e[3][3], u0[3], pr[3], kk;
    for (int i = 0; i < 3; i++)
        for (int j = 0; j < 3; j++)
            m0[i][j] = mq[i][j];
    sym3_eig(mq, e); /* M = E · diag(l) · E^T */
    for (int i = 0; i < 3; i++)
        if (!(mq[i][i] > 0.0f))
            return -2;
    for (int i = 0; i < 3; i++) /* центр: u0 = −M^-1 · (p, q, r) */
        pr[i] = (e[0][i] * sol[6] + e[1][i] * sol[7] + e[2][i] * sol[8]) / mq[i][i];
    for (int i = 0; i < 3; i++)
        u0[i] = -(e[i][0] * pr[0] + e[i][1] * pr[1] + e[i][2] * pr[2]);
    kk = 1.0f; /* (u−u0)^T M (u−u0) = 1 + u0^T M u0 */
    for (int i = 0; i < 3; i++)
        for (int j = 0; j < 3; j++)
            kk += u0[i] * m0[i][j] * u0[j];
    if (!(kk > 0.0f))
        return -2;
    for (int i = 0; i < 3; i++) {
        v[i] = f->c[i] + u0[i] / f->scale;
        for (int j = 0; j < 3; j++) { /* w = E · diag(sqrt(l / kk)) · E^T, в единицах датчика */
            float s = 0.0f;
            for (int k = 0; k < 3; k++)
                s += e[i][k] * sqrtf(mq[k][k] / kk) * e[j][k];
            w[i][j] = s * f->scale;
        }
    }
    return 0;
}

void magcal_apply(const float v[3], const float w[3][3], const float m[3], float out[3])
{
    float d[3] = { m[0] - v[0], m[1] - v[1], m[2] - v[2] };
    for (int i = 0; i < 3; i++)
        out[i] = w[i][0] * d[0] + w[i][1] * d[1] + w[i][2] * d[2];
}

Next: section 99в "NEO-6M GPS receiver: NMEA over UART, sentence parsing, checksum, time and coordinates, PPS" — NMEA sentences and their checksum, coordinates and time without float, the distance between points, PPS on a TIM2 capture and UBX commands.

Open in the interactive handbook