Перейти к содержимому
k14

k14. Математика дрона: углы Эйлера, кватернионы и DCM

k14. Математика дрона: углы Эйлера, кватернионы и DCM

UAV Python Pro · Конспект K14 · Модуль M2. Математика дрона: углы Эйлера, кватернионы и DCM · Редакция 1.0 · После CHECKPOINT-K13

На прошлом уроке мы остановились на границе: векторы и земные системы координат освоены, но полный пересчёт между связанной системой body и земной NED требует знания ориентации аппарата — того, как именно повёрнут его корпус. Этот урок посвящён трём способам записать ориентацию: углам Эйлера (крен, тангаж, рыскание), матрице поворота (DCM) и кватерниону. Вы увидите, что все три способа описывают один и тот же поворот, научитесь переводить один в другой каноническими формулами, обнаружите слабое место углов Эйлера — gimbal lock — и поймёте, почему автопилоты хранят ориентацию в кватернионах, а людям показывают углы.

Ориентация аппарата — это ответ на вопрос «как повёрнут корпус относительно Земли». Записать этот ответ числами можно тремя способами: тремя углами Эйлера, матрицей поворота из девяти чисел или кватернионом из четырёх чисел. Все три записи описывают один и тот же поворот, и между ними существуют точные формулы перехода. Углы понятны человеку, матрица удобна для пересчёта векторов, а кватернион не теряет степень свободы при вертикальном тангаже и дёшево складывает повороты — поэтому именно его используют внутри автопилотов и передают в сообщении ATTITUDE_QUATERNION.

Содержание

1. Цели урока

После изучения этого урока вы будете уметь выполнять следующие действия.

  • Вы объясните, что такое ориентация аппарата, и назовёте три способа её записать: углы Эйлера, матрица поворота, кватернион.
  • Вы определите крен, тангаж и рыскание по осям связанной системы и проверите положительное направление по правилу правой руки из урока K13.
  • Вы построите матрицу поворота по рысканию и полную матрицу body → NED по трём углам с помощью функции euler_to_dcm().
  • Вы проверите свойства матрицы поворота: ортонормированность через np.dot(R, R.T), единичный определитель через np.linalg.det(), обратный поворот через транспонирование R.T.
  • Вы продемонстрируете gimbal lock численно: две разные тройки углов при тангаже 90° дают одинаковую ориентацию.
  • Вы переведёте углы Эйлера в кватернион и обратно каноническими формулами и объясните, зачем нужен math.atan2() и защемление аргумента math.asin().
  • Вы напишете тесты математики ориентации с допусками np.allclose() и pytest.approx.
Связь с разработкой программного обеспечения для БПЛА. Сообщение MAVLink ATTITUDE приносит углы Эйлера в радианах вместе с угловыми скоростями, а сообщение ATTITUDE_QUATERNION — ориентацию в кватернионе. Чтобы наклонить камеру на цель, рассчитать снос или просто нарисовать горизонт в своей наземной станции, вы будете читать эти поля и переводить их из одной формы в другую. Подробный разбор сообщений — в модуле M5, а математика для них готовится сейчас.
↑ К оглавлению

2. Словарь урока

ТерминПростыми словами
Ориентация (attitude) Ответ на вопрос «как повёрнут корпус аппарата относительно Земли».
Крен (roll) Наклон вбок вокруг продольной оси X: правое крыло вниз — положительный крен.
Тангаж (pitch) Наклон носом вверх или вниз вокруг поперечной оси Y: нос вверх — положительный тангаж.
Рыскание (yaw) Поворот носа вправо или влево вокруг вертикальной оси Z: нос вправо — положительное рыскание.
Углы Эйлера Способ записать ориентацию тремя последовательными поворотами вокруг осей; в авиации — схема ZYX: рыскание, тангаж, крен.
Матрица поворота Таблица из девяти чисел три на три, которая превращает координаты вектора в одной системе в координаты в другой.
DCM Direction cosine matrix, матрица направляющих косинусов: то же, что матрица поворота; элементы — косинусы углов между осями двух систем.
Единичная матрица Матрица с единицами по диагонали и нулями в остальных клетках: поворот «ничего не делать»; в numpy — np.eye(3).
Транспонирование Отражение матрицы относительно диагонали: строки становятся столбцами. Для матрицы поворота это обратный поворот.
Определитель (детерминант) Число-характеристика матрицы: у чистого поворота оно равно единице; минус единица означала бы зеркальное отражение.
Gimbal lock Потеря одной степени свободы у углов Эйлера при тангаже ±90°: крен и рыскание начинают делать одно и то же.
Кватернион Четыре числа (w, x, y, z) единичной длины, которые кодируют поворот «ось плюс угол» без потери степеней свободы.
atan2 Арктангенс двух аргументов: возвращает угол в правильном квадранте, от −π до π; в Python — math.atan2(y, x).
Угловая скорость Как быстро меняется ориентация: число радианов в секунду вокруг каждой оси связанной системы.
↑ К оглавлению

3. Зачем нужна ориентация: мост между body и NED

Вспомним вывод урока K13. Датчики аппарата — акселерометр и гироскоп — живут в связанной системе body: они измеряют ускорения и скорости вращения вдоль осей корпуса и «не знают», куда корпус смотрит относительно Земли. Позиция, скорость и маршрут живут в земной системе NED. Между этими двумя мирами нужен мост, и строится он из ориентации: зная, как повёрнут корпус, можно пересчитать любой вектор из связанной системы в земнюю и обратно.

Пример из практики: акселерометр в ровном висении показывает ускорение 9,81 м/с² вдоль своей оси Z — «вниз по корпусу». Если аппарат наклонён, часть этого числа «проецируется» на продольную ось, и наивный код решит, что дрон разгоняется вперёд. Автопилот не ошибается именно потому, что знает свою ориентацию и поворачивает вектор из body в NED до того, как делать выводы. Тот же пересчёт понадобится вам, например, чтобы наклонить подвес камеры на цель: цель известна в земных координатах, а моторы подвеса работают в осях корпуса.

Ориентацию можно записать тремя способами, и этот урок разбирает все три: углы Эйлера (понятны человеку), матрица поворота (удобна для пересчёта векторов) и кватернион (надёжен для хранения и сложения поворотов). Профессионал свободно выбирает нужный способ и не путается между ними.

↑ К оглавлению

4. Углы Эйлера: крен, тангаж, рыскание

Ориентация аппарата в авиации традиционно описывается тремя углами Эйлера — тремя углами последовательного поворота, которые переводят корпус из «ровного» положения в текущее. Каждый угол вращает аппарат вокруг своей оси связанной системы: крен (roll) наклоняет аппарат вбок вокруг продольной оси X, тангаж (pitch) поднимает или опускает нос вокруг поперечной оси Y, а рыскание (yaw) поворачивает нос вправо или влево вокруг вертикальной оси Z.

Крен (roll, φ) — вокруг оси X «вперёд»:
    правое крыло вниз — положительный крен.

Тангаж (pitch, θ) — вокруг оси Y «вправо»:
    нос вверх — положительный тангаж.

Рыскание (yaw, ψ) — вокруг оси Z «вниз»:
    нос вправо, то есть по часовой стрелке при взгляде сверху, —
    положительное рыскание.

Знаки направлений следуют прямо из правила правой руки, которое вы освоили на уроке K13: сожмите правую руку так, чтобы отставленный большой палец смотрел вдоль положительного направления оси, — согнутые пальцы покажут положительное направление вращения вокруг этой оси. Для оси X «вперёд» это наклон правого борта вниз, для оси Y «вправо» — подъём носа, для оси Z «вниз» — поворот носа вправо.

Вторая важная договорённость — порядок поворотов. В авиационной схеме, которую используют ArduPilot и PX4, повороты применяются в порядке ZYX: сначала рыскание вокруг вертикальной оси, затем тангаж вокруг новой поперечной оси, затем крен вокруг новой продольной оси. Порядок принципиален, потому что повороты вокруг разных осей не переставляемы: «поднять нос на 90°, затем повернуть направо 90°» и «повернуть направо 90°, затем поднять нос на 90°» приводят аппарат в разные положения. Схема записи углов без указания порядка — неполная схема, и это частая причина расхождений между библиотеками.

Диапазоны углов: крен и рыскание обычно живут в пределах от −180° до +180°, а тангаж ограничен диапазоном от −90° до +90°. Именно эта ограниченность тангажа станет источником проблемы, которую разбирает раздел 6. Сообщение MAVLink ATTITUDE отдаёт поля roll, pitch и yaw в радианах; для человека их переводят в градусы функцией math.degrees(). Справочное описание сообщения доступно по адресу https://mavlink.io/en/messages/common.html (раздел ATTITUDE).

↑ К оглавлению

5. Матрица поворота и DCM

Матрица поворота — это таблица из девяти чисел, расположенных три на три, которая превращает координаты вектора в одной системе координат в координаты того же вектора в другой системе. В numpy матрица создаётся функцией np.array() из списка списков, а умножение матрицы на вектор выполняет уже знакомая функция np.dot():

\[ \vec{v}_{\mathrm{NED}} = R \cdot \vec{v}_{\mathrm{body}} \]

Словами: чтобы получить земные координаты вектора, нужно умножить матрицу поворота на столбец его связанных координат. Начнём с самого простого поворота — только рыскание, аппарат летит «плоско», без крена и тангажа. Такую матрицу вы фактически уже видели на уроке K13, когда раскладывали скорость по курсу:

\[ R_z(\psi) = \begin{pmatrix} \cos\psi & -\sin\psi & 0 \\ \sin\psi & \cos\psi & 0 \\ 0 & 0 & 1 \end{pmatrix} \]

Чистый код

# Пример: поворот только по рысканию (фрагмент k14_task1_yaw_rotation.py)
import math
import numpy as np

def yaw_rotation_matrix(yaw_rad: float):
    c = math.cos(yaw_rad)
    s = math.sin(yaw_rad)
    return np.array([
        [c, -s, 0.0],
        [s,  c, 0.0],
        [0.0, 0.0, 1.0],
    ])

def rotate_body_to_ned(v_body, yaw_rad: float):
    matrix = yaw_rotation_matrix(yaw_rad)
    return np.dot(matrix, np.asarray(v_body, dtype=float))
  • Построчный разбор

    Ниже приведён тот же фрагмент с пояснением после каждой смысловой строки. Строки, которые начинаются с последовательности символов «# →», содержат комментарий преподавателя. Эти строки не являются обязательной частью программы.

    import math
    import numpy as np
    # → math даёт синус и косинус, numpy — массивы и умножение матриц.
    
    def yaw_rotation_matrix(yaw_rad: float):
    # → Вход — угол рыскания в радианах; выход — матрица три на три.
    
        c = math.cos(yaw_rad)
        s = math.sin(yaw_rad)
    # → Короткие имена c и s: каждая пара значений используется в матрице трижды.
    
        return np.array([
            [c, -s, 0.0],
            [s,  c, 0.0],
            [0.0, 0.0, 1.0],
        ])
    # → Список списков становится двумерным массивом numpy — матрицей.
    # → Третья строка (0, 0, 1) означает: вертикальная компонента не меняется —
    # → поворот идёт строго вокруг вертикальной оси.
    
    def rotate_body_to_ned(v_body, yaw_rad: float):
    # → Поворот произвольного вектора из body в NED.
    
        matrix = yaw_rotation_matrix(yaw_rad)
        return np.dot(matrix, np.asarray(v_body, dtype=float))
    # → np.dot(матрица, вектор) выполняет умножение матрицы на столбец:
    # → каждая строка матрицы умножается на вектор и даёт одну новую компоненту.

Проверьте результат мысленно: при курсе 90° «нос» аппарата — вектор (1, 0, 0) в связанной системе — должен смотреть на восток, то есть превратиться в (0, 1, 0) в NED. Демо практики печатает ровно это.

Полная матрица ориентации по трём углам называется DCM — direction cosine matrix, «матрица направляющих косинусов». Имя объясняет устройство таблицы: каждый её элемент равен косинусу угла между соответствующей осью связанной системы и осью земной. Каноническая формула для схемы ZYX (источник: https://en.wikipedia.org/wiki/Rotation_matrix) с обозначениями \(s_\phi = \sin(\text{крен})\), \(c_\phi = \cos(\text{крен})\), \(s_\theta = \sin(\text{тангаж})\), \(c_\theta = \cos(\text{тангаж})\), \(s_\psi = \sin(\text{рыскание})\), \(c_\psi = \cos(\text{рыскание})\):

\[ R_{\mathrm{body} \to \mathrm{NED}} = \begin{pmatrix} c_\theta c_\psi & s_\phi s_\theta c_\psi - c_\phi s_\psi & c_\phi s_\theta c_\psi + s_\phi s_\psi \\ c_\theta s_\psi & s_\phi s_\theta s_\psi + c_\phi c_\psi & c_\phi s_\theta s_\psi - s_\phi c_\psi \\ -s_\theta & s_\phi c_\theta & c_\phi c_\theta \end{pmatrix} \]

Выводить эту формулу вручную мы не будем — это занятие учебника по математике полёта, а не курса интеграции. Наша задача другая: взять каноническую формулу из надёжного источника, вставить в код и проверить её свойствами, о которых сказано ниже. Именно так поступают на практике; к этому принципу мы вернёмся в разделе 8.

Чистый код

# Пример: полная матрица DCM по трём углам (фрагмент k14_task2_euler_dcm.py)
import math
import numpy as np

def euler_to_dcm(roll_rad: float, pitch_rad: float, yaw_rad: float):
    cr, sr = math.cos(roll_rad), math.sin(roll_rad)
    cp, sp = math.cos(pitch_rad), math.sin(pitch_rad)
    cy, sy = math.cos(yaw_rad), math.sin(yaw_rad)
    return np.array([
        [cp * cy, sr * sp * cy - cr * sy, cr * sp * cy + sr * sy],
        [cp * sy, sr * sp * sy + cr * cy, cr * sp * sy - sr * cy],
        [-sp,     sr * cp,                cr * cp],
    ])

def ned_to_body_matrix(roll_rad: float, pitch_rad: float, yaw_rad: float):
    return euler_to_dcm(roll_rad, pitch_rad, yaw_rad).T
  • Построчный разбор

    Ниже приведён тот же фрагмент с пояснением после каждой смысловой строки. Строки, которые начинаются с последовательности символов «# →», содержат комментарий преподавателя. Эти строки не являются обязательной частью программы.

    def euler_to_dcm(roll_rad: float, pitch_rad: float, yaw_rad: float):
    # → Все три угла подаются в радианах: функция не угадывает единицы за вызывающего.
    
        cr, sr = math.cos(roll_rad), math.sin(roll_rad)
        cp, sp = math.cos(pitch_rad), math.sin(pitch_rad)
        cy, sy = math.cos(yaw_rad), math.sin(yaw_rad)
    # → Шесть коротких имён: c — косинус, s — синус;
    # → r — roll (крен), p — pitch (тангаж), y — yaw (рыскание).
    # → Каждое значение входит в формулу несколько раз, поэтому
    # → их вычисляют один раз и переиспользуют.
    
        return np.array([
            [cp * cy, sr * sp * cy - cr * sy, cr * sp * cy + sr * sy],
            [cp * sy, sr * sp * sy + cr * cy, cr * sp * sy - sr * cy],
            [-sp,     sr * cp,                cr * cp],
        ])
    # → Таблица три на три — дословная запись канонической формулы ZYX.
    # → Сверьте её с формулой выше элемент за элементом: это и есть
    # → «перенос формулы из источника в код без изобретательства».
    
    def ned_to_body_matrix(roll_rad: float, pitch_rad: float, yaw_rad: float):
        return euler_to_dcm(roll_rad, pitch_rad, yaw_rad).T
    # → Обратный поворот NED -> body не нужно выводить заново:
    # → атрибут .T транспонирует матрицу (строки становятся столбцами),
    # → а для матрицы поворота транспонированная матрица и есть обратная.

У матрицы поворота есть три свойства, которые превращают её в самопроверяемую структуру. Первое: она ортонормирована — произведение матрицы на её транспонированную версию даёт единичную матрицу (в numpy — np.eye(3), единицы по диагонали, нули в остальных клетках). Второе: её определитель (функция np.linalg.det()) равен единице; определитель минус единица означал бы, что в формулу закралось зеркальное отражение. Третье: обратный поворот — это транспонирование: столбцы и строки меняются местами, и обратная матрица достаётся бесплатно, без сложного обращения. Тесты практики проверяют все три свойства на «живой» матрице — это гораздо надёжнее, чем однажды сверить числа глазами.

↑ К оглавлению

6. Gimbal lock: потеря степени свободы

У углов Эйлера есть врождённый дефект, и понять его помогает механическая картинка. Представьте карданный подвес — конструкцию из трёх вложенных колец на осях, как в гироскопе или в подвесе съёмочной камеры: внешнее кольцо вращается вокруг вертикальной оси (рыскание), среднее — вокруг поперечной (тангаж), внутреннее — вокруг продольной (крен). Пока кольца разведены, три двигателя по одному на кольцо дают аппарату внутри полную свободу ориентации.

Карданный подвес из трёх рамок:

   внешняя рамка   — рыскание (ось Z, вертикаль)
      среднее кольцо — тангаж   (ось Y, поперечная)
         внутреннее кольцо — крен (ось X, продольная)

Если поднять среднее кольцо ровно на 90 град,
оси внешнего и внутреннего колец совпадают:
два разных двигателя крутят аппарат вокруг одной
и той же оси — одна степень свободы потеряна.

Этот эффект и называется gimbal lock (дословно — «блокировка подвеса»). На языке чисел: при тангаже ровно ±90° ориентация перестаёт зависеть от крена и рыскания по отдельности и начинает зависеть только от их разности. Два разных набора углов описывают одно и то же положение корпуса — представление перестаёт быть однозначным. Описание эффекта с иллюстрациями доступно по адресу https://en.wikipedia.org/wiki/Gimbal_lock.

Проверим это численно — именно так, как инженер проверяет подозрение: не рассуждением, а экспериментом.

Чистый код

# Пример: численная демонстрация gimbal lock (фрагмент k14_task4_gimbal_lock_demo.py)
import math
import numpy as np
from k14_task2_euler_dcm import euler_to_dcm

dcm_a = euler_to_dcm(math.radians(30.0), math.radians(20.0), math.radians(10.0))
dcm_b = euler_to_dcm(math.radians(50.0), math.radians(20.0), math.radians(30.0))
print(np.allclose(dcm_a, dcm_b))   # False: тангаж 20 град, всё различимо

dcm_c = euler_to_dcm(math.radians(30.0), math.radians(90.0), math.radians(10.0))
dcm_d = euler_to_dcm(math.radians(50.0), math.radians(90.0), math.radians(30.0))
print(np.allclose(dcm_c, dcm_d))   # True: тангаж 90 град, степень свободы потеряна
  • Построчный разбор

    Ниже приведён тот же фрагмент с пояснением после каждой смысловой строки. Строки, которые начинаются с последовательности символов «# →», содержат комментарий преподавателя. Эти строки не являются обязательной частью программы.

    import math
    import numpy as np
    from k14_task2_euler_dcm import euler_to_dcm
    # → Берём готовую функцию DCM из задачи 14.2 — модули работают вместе.
    
    dcm_a = euler_to_dcm(math.radians(30.0), math.radians(20.0), math.radians(10.0))
    dcm_b = euler_to_dcm(math.radians(50.0), math.radians(20.0), math.radians(30.0))
    # → Две ориентации при обычном тангаже 20 град.
    # → Крен и рыскание увеличились одинаково на 20 град.
    
    print(np.allclose(dcm_a, dcm_b))   # False: тангаж 20 град, всё различимо
    # → np.allclose сравнивает две матрицы с допуском (урок K13):
    # → при нормальном тангаже ориентации разные — False.
    
    dcm_c = euler_to_dcm(math.radians(30.0), math.radians(90.0), math.radians(10.0))
    dcm_d = euler_to_dcm(math.radians(50.0), math.radians(90.0), math.radians(30.0))
    # → Тот же сдвиг «крен +20, рыскание +20», но тангаж ровно 90 град:
    # → нос смотрит вертикально вверх.
    
    print(np.allclose(dcm_c, dcm_d))   # True: степень свободы потеряна
    # → Матрицы совпали: две разные тройки углов описывают одно положение.
    # → Различить крен 30 с рысканием 10 и крен 50 с рысканием 30
    # → по ориентации корпуса невозможно — обе дают разность 20 град.

Почему это важно для беспилотника, а не только для учебника? Во-первых, около вертикального положения перестают быть однозначными сами числа углов: алгоритм, который по матрице восстанавливает крен и рыскание, начнёт выдавать скачущие значения при почти неподвижном аппарате. Во-вторых, плавное изменение ориентации (интерполяция) вблизи блокировки ломается. Для аппаратов вертикального взлёта, включая наш целевой Matek F405 VTOL в квадопланной конфигурации, большие углы тангажа — рабочий режим переходных фаз, поэтому проблема касается нас напрямую.

Практический вывод. Ориентацию хранят и интегрируют не в углах Эйлера, а в кватернионах — о них следующий раздел. Углы Эйлера оставляют для двух ролей: интерфейс с человеком (показать на экране) и интерфейс с простой логикой (сравнить крен с порогом). Внутреннее представление — кватернион или матрица.
↑ К оглавлению

7. Кватернион: четыре числа вместо трёх углов

Кватернион ориентации — это четыре числа, которые записываются как (w, x, y, z) и образуют вектор единичной длины. Полная математика кватернионов выходит за рамки курса и не нужна для работы: достаточно твёрдо усвоить, что кватернион кодирует поворот как «ось вращения плюс угол вокруг неё», что его норма (длина) у корректной ориентации равна единице и что между кватернионом, углами Эйлера и матрицей DCM существуют точные формулы перехода.

Два главных преимущества кватерниона вытекают из его устройства. Первое: у него нет gimbal lock — все ориентации, включая вертикальные, представляются одинаково хорошо. Второе: два последовательных поворота складываются простым перемножением кватернионов, а численная погрешность легко чинится нормированием — делением четырёх компонент на их общую длину. Именно поэтому автопилот ведёт ориентацию в кватернионах, а MAVLink, помимо ATTITUDE, предлагает сообщение ATTITUDE_QUATERNION.

Формула перехода от углов Эйлера (схема ZYX) к кватерниону — каноническая (источник: https://en.wikipedia.org/wiki/Conversion_between_quaternions_and_Euler_angles). В ней участвуют синусы и косинусы половин углов; для краткости обозначим \(c_r = \cos(\phi/2)\), \(s_r = \sin(\phi/2)\) и так же для тангажа (p) и рыскания (y):

\[ \begin{aligned} w &= c_r c_p c_y + s_r s_p s_y \\ x &= s_r c_p c_y - c_r s_p s_y \\ y &= c_r s_p c_y + s_r c_p s_y \\ z &= c_r c_p s_y - s_r s_p c_y \end{aligned} \]

Чистый код

# Пример: углы Эйлера -> кватернион (фрагмент k14_task3_quaternion.py)
import math

def euler_to_quaternion(roll_rad: float, pitch_rad: float, yaw_rad: float):
    cr, sr = math.cos(roll_rad / 2.0), math.sin(roll_rad / 2.0)
    cp, sp = math.cos(pitch_rad / 2.0), math.sin(pitch_rad / 2.0)
    cy, sy = math.cos(yaw_rad / 2.0), math.sin(yaw_rad / 2.0)
    w = cr * cp * cy + sr * sp * sy
    x = sr * cp * cy - cr * sp * sy
    y = cr * sp * cy + sr * cp * sy
    z = cr * cp * sy - sr * sp * cy
    return (w, x, y, z)
  • Построчный разбор

    Ниже приведён тот же фрагмент с пояснением после каждой смысловой строки. Строки, которые начинаются с последовательности символов «# →», содержат комментарий преподавателя. Эти строки не являются обязательной частью программы.

    import math
    
    def euler_to_quaternion(roll_rad: float, pitch_rad: float, yaw_rad: float):
    # → Вход — три угла в радианах, выход — кортеж из четырёх чисел (w, x, y, z).
    
        cr, sr = math.cos(roll_rad / 2.0), math.sin(roll_rad / 2.0)
        cp, sp = math.cos(pitch_rad / 2.0), math.sin(pitch_rad / 2.0)
        cy, sy = math.cos(yaw_rad / 2.0), math.sin(yaw_rad / 2.0)
    # → Ключевая деталь формулы: берутся половинные углы.
    # → Кватернион «живёт» в половине угла — это факт, а не приём упрощения.
    
        w = cr * cp * cy + sr * sp * sy
        x = sr * cp * cy - cr * sp * sy
        y = cr * sp * cy + sr * cp * sy
        z = cr * cp * sy - sr * sp * cy
    # → Четыре строки — дословная каноническая формула из источника.
    # → Компонента w близка к единице при малых углах и убывает
    # → по мере роста поворота: это «косинус половины угла».
    
        return (w, x, y, z)
    # → Порядок компонент фиксируем явно: сначала w, затем x, y, z.
    # → Другие библиотеки иногда хранят порядок (x, y, z, w) —
    # → перепутанный порядок является классическим источником ошибок.

Обратный переход — от кватерниона к углам — использует две новые функции модуля math. Первая: atan2 — арктангенс двух аргументов, math.atan2(y, x). Обычный арктангенс видит только отношение двух чисел и потому путает противоположные квадранты; atan2 получает оба числа по отдельности, учитывает знаки и возвращает угол в полном диапазоне от −π до π. Вторая: math.asin() — арксинус, определённый только для аргументов от −1 до 1. Из-за погрешности округления значение, математически равное единице, может прийти как 1.0000000000000002, и тогда asin поднимет ValueError. Поэтому аргумент предварительно «защемляют» в допустимых границах.

\[ \phi = \operatorname{atan2}\bigl(2(wx + yz),\; 1 - 2(x^2 + y^2)\bigr) \]

\[ \theta = \arcsin\bigl(\operatorname{clip}(2(wy - zx),\; -1,\; 1)\bigr) \]

\[ \psi = \operatorname{atan2}\bigl(2(wz + xy),\; 1 - 2(y^2 + z^2)\bigr) \]

Чистый код

# Пример: кватернион -> углы Эйлера (фрагмент k14_task3_quaternion.py)
import math

def quaternion_to_euler(q):
    w, x, y, z = (float(c) for c in q)
    roll = math.atan2(2.0 * (w * x + y * z), 1.0 - 2.0 * (x * x + y * y))
    sin_pitch = 2.0 * (w * y - z * x)
    sin_pitch = max(-1.0, min(1.0, sin_pitch))
    pitch = math.asin(sin_pitch)
    yaw = math.atan2(2.0 * (w * z + x * y), 1.0 - 2.0 * (y * y + z * z))
    return (roll, pitch, yaw)
  • Построчный разбор

    Ниже приведён тот же фрагмент с пояснением после каждой смысловой строки. Строки, которые начинаются с последовательности символов «# →», содержат комментарий преподавателя. Эти строки не являются обязательной частью программы.

    import math
    
    def quaternion_to_euler(q):
    # → Вход — кватернион (w, x, y, z), выход — три угла в радианах.
    
        w, x, y, z = (float(c) for c in q)
    # → Распаковка четырёх компонент с приведением к float:
    # → генераторное выражение применяет float() к каждому элементу.
    
        roll = math.atan2(2.0 * (w * x + y * z), 1.0 - 2.0 * (x * x + y * y))
    # → Крен: atan2 от двух выражений канонической формулы.
    # → Два аргумента вместо одного отношения — гарантия правильного квадранта.
    
        sin_pitch = 2.0 * (w * y - z * x)
    # → Синус тангажа из той же канонической формулы.
    
        sin_pitch = max(-1.0, min(1.0, sin_pitch))
    # → Защемление (clip): min не пускает значение выше 1,
    # → max не пускает ниже −1. Без этой строки asin однажды упадёт
    # → с ValueError: math domain error из-за погрешности в 17-м знаке.
    
        pitch = math.asin(sin_pitch)
    # → Арксинус возвращает угол в диапазоне от −90 до +90 град —
    # → ровно законный диапазон тангажа.
    
        yaw = math.atan2(2.0 * (w * z + x * y), 1.0 - 2.0 * (y * y + z * z))
    # → Рыскание: снова atan2, полный круг без разрывов.
    
        return (roll, pitch, yaw)
    # → Результат в радианах; для печати в градусах вызывающий код
    # → применяет math.degrees().
Заметьте. Вблизи тангажа ±90° формула quaternion_to_euler() по-прежнему корректна как математика, но сами углы перестают быть устойчивыми: крен и рыскание могут прыгать, оставаясь в рамках своей разности. Это не ошибка формулы, а свойство углового представления — тот самый gimbal lock. Кватернион при этом остаётся плавным и однозначным.
↑ К оглавлению

8. Когда не следует писать свою математику

Этот раздел короткий, но профессионально важный. Математика ориентации выведена, перепроверена и опубликована десятилетия назад; десятки библиотек реализуют её с тестами и исправленными краевыми случаями. Самостоятельно «улучшать» формулы — значит гарантированно получить трудноуловимую ошибку знака или порядка осей, которая проявится не на столе, а в полёте. Поэтому в отрасли принята схема работы, которую мы воспроизвели в этом уроке и которую вам стоит сделать своей привычкой.

  • Берите формулу из надёжного источника и сохраняйте ссылку в комментарии к коду — так сделано в файлах k14_task2_euler_dcm.py и k14_task3_quaternion.py.
  • Явно фиксируйте соглашения: схема углов (ZYX), порядок компонент кватерниона (w, x, y, z), единицы (радианы), системы координат (body, NED).
  • Покрывайте реализацию тестами свойств: ортонормированность матрицы, единичный определитель, единичная норма кватерниона, круговые переводы «туда и обратно».
  • Когда проект вырастет из учебного, переходите на проверенные реализации: в экосистеме MAVLink помощники живут в модуле mavextra библиотеки pymavlink (модуль M5), в научных проектах — в классе scipy.spatial.transform.Rotation. Ставить их сейчас не нужно: урок сознательно проходит по формулам один раз, чтобы вы понимали, что именно делают библиотеки.
Формула профессии. Архитектуру программы изобретайте сами — этому посвящён весь курс. Математику берите каноническую, из источника, и обкладывайте тестами. Граница проста: ваш код отвечает за то, какие данные откуда приходят и куда уходят; за правильность поворота отвечают проверенная формула и её тесты.
↑ К оглавлению

9. Угловая скорость: как быстро меняется ориентация

Рядом с углами ориентации в телеметрии всегда живёт вторая величина — угловая скорость: как быстро аппарат вращается вокруг каждой из осей связанной системы. Её измеряет гироскоп, и MAVLink отдаёт её в полях rollspeed, pitchspeed и yawspeed сообщения ATTITUDE — в радианах в секунду. Не путайте две величины: ориентация отвечает на вопрос «куда смотрит корпус», угловая скорость — «как быстро он поворачивается». В ровном висении углы почти постоянны, а угловые скорости дрожат вокруг нуля; в резком развороте скорость рыскания велика, а сам угол рыскания меняется плавно.

Связь между величинами прямая: ориентация — это, грубо говоря, накопленный итог угловой скорости (математически — её интеграл по времени). Автопилот вычисляет ориентацию сотни раз в секунду, смешивая показания гироскопа, акселерометра, барометра и спутников в фильтре, который называется EKF (Extended Kalman Filter). Внутреннее устройство фильтра — большая отдельная тема за рамками курса; нам достаточно знать, что на выходе EKF лежат те самые ориентации в углах и кватернионах, которые мы научились читать и переводить, а угловая скорость используется в фильтре как главный источник «быстрой» информации о вращении.

↑ К оглавлению

10. Тесты математики ориентации

Тесты этого урока продолжают идею K13: математические функции проверяются с допусками (np.allclose() для матриц и массивов, pytest.approx для чисел и кортежей), а вместо «сверил числа глазами» проверяются свойства и круговые переводы. Перечислим четыре класса проверок, которые используются в файле k14_task5_test_attitude.py.

  • Опорные случаи: при рыскании 90° вектор «нос» обязан превратиться из северного в восточный; при нулевых углах матрица обязана быть единичной.
  • Свойства структуры: произведение матрицы на транспонированную равно единичной матрице, определитель равен единице — независимо от конкретных углов.
  • Круговые переводы: углы → кватернион → углы обязаны вернуть исходные значения; поворот вперёд и обратно через транспонированную матрицу обязан вернуть исходный вектор.
  • Вырожденный случай: при тангаже 90° две разные тройки углов обязаны дать одинаковую матрицу — тест фиксирует gimbal lock как ожидаемое поведение формулы, а не как сюрприз.

Чистый код

# Пример: тесты свойств DCM и кругового перевода (фрагмент k14_task5_test_attitude.py)
import math
import numpy as np
import pytest
from k14_task2_euler_dcm import euler_to_dcm
from k14_task3_quaternion import euler_to_quaternion, quaternion_to_euler

def test_dcm_is_orthonormal():
    matrix = euler_to_dcm(math.radians(10.0), math.radians(-5.0), math.radians(30.0))
    assert np.allclose(np.dot(matrix, matrix.T), np.eye(3))

def test_euler_quaternion_round_trip():
    angles = (math.radians(10.0), math.radians(-5.0), math.radians(30.0))
    q = euler_to_quaternion(*angles)
    back = quaternion_to_euler(q)
    assert back == pytest.approx(angles)
  • Построчный разбор

    Ниже приведён тот же фрагмент с пояснением после каждой смысловой строки. Строки, которые начинаются с последовательности символов «# →», содержат комментарий преподавателя. Эти строки не являются обязательной частью программы.

    import math
    import numpy as np
    import pytest
    from k14_task2_euler_dcm import euler_to_dcm
    from k14_task3_quaternion import euler_to_quaternion, quaternion_to_euler
    # → Тесты импортируют функции из модулей практики —
    # → стандартная схема урока K11: логика отдельно, проверки отдельно.
    
    def test_dcm_is_orthonormal():
        matrix = euler_to_dcm(math.radians(10.0), math.radians(-5.0), math.radians(30.0))
        assert np.allclose(np.dot(matrix, matrix.T), np.eye(3))
    # → Свойство, а не конкретные числа: для любых углов
    # → R · Rᵀ обязана давать единичную матрицу np.eye(3).
    # → np.allclose сравнивает матрицы поэлементно с допуском.
    
    def test_euler_quaternion_round_trip():
        angles = (math.radians(10.0), math.radians(-5.0), math.radians(30.0))
        q = euler_to_quaternion(*angles)
        back = quaternion_to_euler(q)
        assert back == pytest.approx(angles)
    # → Круговой перевод: звёздочка в вызове распаковывает кортеж
    # → в три отдельных аргумента. Исходные и возвращённые углы
    # → обязаны совпасть с точностью до допуска pytest.approx.
↑ К оглавлению

11. Типичные ошибки

ОшибкаЧто происходитКак следует рассуждать
Градусы поданы в тригонометрию без перевода или результат atan2 напечатан как градусы Числа выглядят совершенно правдоподобно, но неверны по величине Внутри математики — всегда радианы; на границе с человеком — math.radians() туда и math.degrees() обратно
Порядок компонент кватерниона перепутан: (w, x, y, z) вместо (x, y, z, w) Ориентация «шумит», углы скачут, аппарат в симуляторе ведёт себя неадекватно Порядок фиксируйте в имени и комментарии; при стыковке библиотек сверяйте документацию каждой
Хранение и интегрирование ориентации в углах Эйлера Вблизи тангажа ±90° значения скачут, плавность теряется — gimbal lock Внутри — кватернион или матрица; углы — только для отображения и пороговой логики
math.asin() вызван без защемления аргумента Рано или поздно ValueError: math domain error из-за погрешности в 17-м знаке Перед asin зажимайте значение в границы от −1 до 1 через max/min
Своя «улучшенная» формула поворота вместо канонической Тонкая ошибка знака или порядка осей, которая всплывает только в полёте Формула — из источника со ссылкой в комментарии; корректность — через тесты свойств
Проверка матриц на точное равенство (==) Тест падает из-за округления в последних знаках Для массивов — np.allclose(), для чисел и кортежей — pytest.approx
Схема углов не указана: ZYX перепутана с XYZ Ориентация расходится с автопилотом на больших углах Схему записывайте в docstring функции; ArduPilot и PX4 используют ZYX
↑ К оглавлению

12. Практика

Практика урока состоит из пяти файлов: четыре модуля математики ориентации и один файл тестов. Порядок работы: создайте файлы задач 14.1–14.5 в каталоге code/M01_python, выполните прогоны из задачи 14.6 и сохраните исходники в Git (задача 14.7). Библиотека numpy уже установлена в уроке K13 — новых пакетов этот урок не требует. В карточках в аккордеонах лежит полный текст каждого файла; он совпадает с содержимым репозитория курса.

Задача 14.1. Поворот по рысканию

Файл практики: k14_task1_yaw_rotation.py.

Создайте модуль простейшего поворота: функция yaw_rotation_matrix() строит матрицу поворота вокруг вертикальной оси, а функция rotate_body_to_ned() умножает её на вектор через np.dot(). Демо поворачивает «нос» и «правое крыло» аппарата на курсы 0°, 90° и 180°.

Ожидаемый результат. Демо печатает для курса 90° нос в NED [0. 1. 0.] (восток) и правый борт в NED [-1. 0. 0.] (юг).

  • Полный текст файла k14_task1_yaw_rotation.py

    Скопируйте приведённое ниже содержимое в файл code/M01_python/k14_task1_yaw_rotation.py в проекте PyCharm (виртуальное окружение) и запустите его из каталога code/M01_python. Ниже приведён полный учебный текст файла; он совпадает с файлом в репозитории курса.

    # k14_task1_yaw_rotation.py
    # Цель: поворот вокруг вертикальной оси (только рыскание) —
    #       первый шаг от векторов K13 к полной ориентации K14.
    # Запуск из каталога code/M01_python:
    #   python k14_task1_yaw_rotation.py
    
    import math
    
    import numpy as np
    
    
    def yaw_rotation_matrix(yaw_rad: float):
        """Матрица поворота body -> NED вокруг вертикальной оси на угол yaw.
    
        Крен и тангаж предполагаются нулевыми: аппарат летит «плоско».
        """
        c = math.cos(yaw_rad)
        s = math.sin(yaw_rad)
        return np.array([
            [c, -s, 0.0],
            [s,  c, 0.0],
            [0.0, 0.0, 1.0],
        ])
    
    
    def rotate_body_to_ned(v_body, yaw_rad: float):
        """Повернуть вектор из связанной системы в NED (только рыскание)."""
        matrix = yaw_rotation_matrix(yaw_rad)
        return np.dot(matrix, np.asarray(v_body, dtype=float))
    
    
    if __name__ == "__main__":
        forward_body = (1.0, 0.0, 0.0)  # «нос» аппарата в связанной системе
        right_body = (0.0, 1.0, 0.0)    # «правое крыло» в связанной системе
    
        for yaw_deg in (0.0, 90.0, 180.0):
            yaw_rad = math.radians(yaw_deg)
            nose = rotate_body_to_ned(forward_body, yaw_rad)
            wing = rotate_body_to_ned(right_body, yaw_rad)
            print(
                f"курс {yaw_deg:5.1f} град: нос в NED {np.round(nose, 3)}, "
                f"правый борт в NED {np.round(wing, 3)}"
            )

Задача 14.2. Полная матрица DCM

Файл практики: k14_task2_euler_dcm.py.

Создайте модуль полной ориентации: функция euler_to_dcm() реализует каноническую формулу ZYX со ссылкой на источник в комментарии, а ned_to_body_matrix() возвращает обратный поворот через транспонирование .T. Демо проверяет свойства: ортонормированность и единичный определитель.

Ожидаемый результат. Демо печатает определитель 1.000000, а произведение R @ R.T — единичную матрицу с единицами по диагонали.

  • Полный текст файла k14_task2_euler_dcm.py

    Скопируйте приведённое ниже содержимое в файл code/M01_python/k14_task2_euler_dcm.py в проекте PyCharm (виртуальное окружение) и запустите его из каталога code/M01_python. Ниже приведён полный учебный текст файла; он совпадает с файлом в репозитории курса.

    # k14_task2_euler_dcm.py
    # Цель: полная матрица ориентации body -> NED по углам Эйлера (схема ZYX).
    # Формула каноническая, источник:
    #   https://en.wikipedia.org/wiki/Rotation_matrix
    # Запуск из каталога code/M01_python:
    #   python k14_task2_euler_dcm.py
    
    import math
    
    import numpy as np
    
    
    def euler_to_dcm(roll_rad: float, pitch_rad: float, yaw_rad: float):
        """Матрица поворота из связанной системы body в земную NED (схема ZYX).
    
        Углы в радианах: крен вокруг оси X, тангаж вокруг оси Y, рыскание вокруг оси Z.
        """
        cr, sr = math.cos(roll_rad), math.sin(roll_rad)
        cp, sp = math.cos(pitch_rad), math.sin(pitch_rad)
        cy, sy = math.cos(yaw_rad), math.sin(yaw_rad)
        return np.array([
            [cp * cy, sr * sp * cy - cr * sy, cr * sp * cy + sr * sy],
            [cp * sy, sr * sp * sy + cr * cy, cr * sp * sy - sr * cy],
            [-sp,     sr * cp,                cr * cp],
        ])
    
    
    def ned_to_body_matrix(roll_rad: float, pitch_rad: float, yaw_rad: float):
        """Обратный поворот NED -> body: для матрицы поворота это транспонирование."""
        return euler_to_dcm(roll_rad, pitch_rad, yaw_rad).T
    
    
    if __name__ == "__main__":
        roll, pitch, yaw = math.radians(10.0), math.radians(-5.0), math.radians(30.0)
        matrix = euler_to_dcm(roll, pitch, yaw)
    
        print("матрица body -> NED (крен 10, тангаж -5, рыскание 30 град):")
        print(np.round(matrix, 4))
    
        print("произведение R @ R.T (для чистого поворота это единичная матрица):")
        print(np.round(np.dot(matrix, matrix.T), 4))
    
        print(f"определитель: {np.linalg.det(matrix):.6f} (ожидается 1)")
    
        v_body = np.array([9.81, 0.0, 0.0])
        print(f"вектор {v_body} в body -> NED: {np.round(np.dot(matrix, v_body), 4)}")

Задача 14.3. Кватернион

Файл практики: k14_task3_quaternion.py.

Создайте модуль кватерниона: канонический перевод euler_to_quaternion(), норма quaternion_norm() и обратный перевод quaternion_to_euler() с math.atan2() и защемлением аргумента math.asin(). Демо выполняет круговой перевод углов через кватернион и обратно.

Ожидаемый результат. Демо печатает норму 1.000000 и возвращает исходные углы (10.0, -5.0, 30.0) после кругового перевода.

  • Полный текст файла k14_task3_quaternion.py

    Скопируйте приведённое ниже содержимое в файл code/M01_python/k14_task3_quaternion.py в проекте PyCharm (виртуальное окружение) и запустите его из каталога code/M01_python. Ниже приведён полный учебный текст файла; он совпадает с файлом в репозитории курса.

    # k14_task3_quaternion.py
    # Цель: кватернион ориентации — хранение без потери степени свободы (gimbal lock).
    # Канонические формулы (схема ZYX, порядок компонент w, x, y, z):
    #   https://en.wikipedia.org/wiki/Conversion_between_quaternions_and_Euler_angles
    # Запуск из каталога code/M01_python:
    #   python k14_task3_quaternion.py
    
    import math
    
    import numpy as np
    
    
    def euler_to_quaternion(roll_rad: float, pitch_rad: float, yaw_rad: float):
        """Углы Эйлера (схема ZYX) -> единичный кватернион (w, x, y, z)."""
        cr, sr = math.cos(roll_rad / 2.0), math.sin(roll_rad / 2.0)
        cp, sp = math.cos(pitch_rad / 2.0), math.sin(pitch_rad / 2.0)
        cy, sy = math.cos(yaw_rad / 2.0), math.sin(yaw_rad / 2.0)
        w = cr * cp * cy + sr * sp * sy
        x = sr * cp * cy - cr * sp * sy
        y = cr * sp * cy + sr * cp * sy
        z = cr * cp * sy - sr * sp * cy
        return (w, x, y, z)
    
    
    def quaternion_norm(q) -> float:
        """Норма кватерниона: у кватерниона ориентации она равна единице."""
        return float(np.linalg.norm(np.asarray(q, dtype=float)))
    
    
    def quaternion_to_euler(q):
        """Кватернион (w, x, y, z) -> углы Эйлера (roll, pitch, yaw) в радианах."""
        w, x, y, z = (float(c) for c in q)
        roll = math.atan2(2.0 * (w * x + y * z), 1.0 - 2.0 * (x * x + y * y))
        sin_pitch = 2.0 * (w * y - z * x)
        sin_pitch = max(-1.0, min(1.0, sin_pitch))  # защита от выхода за границы asin
        pitch = math.asin(sin_pitch)
        yaw = math.atan2(2.0 * (w * z + x * y), 1.0 - 2.0 * (y * y + z * z))
        return (roll, pitch, yaw)
    
    
    if __name__ == "__main__":
        angles_deg = (10.0, -5.0, 30.0)
        angles_rad = tuple(math.radians(a) for a in angles_deg)
    
        q = euler_to_quaternion(*angles_rad)
        print(f"углы (град): {angles_deg}")
        print(f"кватернион (w, x, y, z): {tuple(round(c, 4) for c in q)}")
        print(f"норма кватерниона: {quaternion_norm(q):.6f} (ожидается 1)")
    
        back_rad = quaternion_to_euler(q)
        back_deg = tuple(round(math.degrees(a), 4) for a in back_rad)
        print(f"обратно в углы (град): {back_deg}")

Задача 14.4. Демо gimbal lock

Файл практики: k14_task4_gimbal_lock_demo.py.

Создайте модуль численной демонстрации gimbal lock: функция orientation_case() возвращает матрицу и кватернион для тройки углов, а демо сравнивает две разные тройки при тангаже 20° и при тангаже 90° с помощью np.allclose().

Ожидаемый результат. Демо печатает False при тангаже 20° и True для матриц и кватернионов при тангаже 90°: две разные тройки углов описывают одну ориентацию.

  • Полный текст файла k14_task4_gimbal_lock_demo.py

    Скопируйте приведённое ниже содержимое в файл code/M01_python/k14_task4_gimbal_lock_demo.py в проекте PyCharm (виртуальное окружение) и запустите его из каталога code/M01_python. Ниже приведён полный учебный текст файла; он совпадает с файлом в репозитории курса.

    # k14_task4_gimbal_lock_demo.py
    # Цель: численно показать gimbal lock — потерю одной степени свободы
    #       при тангаже +90 градусов (нос аппарата смотрит вертикально вверх).
    # Запуск из каталога code/M01_python:
    #   python k14_task4_gimbal_lock_demo.py
    
    import math
    
    import numpy as np
    
    from k14_task2_euler_dcm import euler_to_dcm
    from k14_task3_quaternion import euler_to_quaternion
    
    
    def orientation_case(roll_deg: float, pitch_deg: float, yaw_deg: float):
        """Одна ориентация: углы в градусах -> матрица DCM и кватернион."""
        roll, pitch, yaw = (math.radians(a) for a in (roll_deg, pitch_deg, yaw_deg))
        return euler_to_dcm(roll, pitch, yaw), euler_to_quaternion(roll, pitch, yaw)
    
    
    if __name__ == "__main__":
        # Случай 1: тангаж далёк от 90 град — разные углы дают разные ориентации.
        dcm_a, _ = orientation_case(30.0, 20.0, 10.0)
        dcm_b, _ = orientation_case(50.0, 20.0, 30.0)
        print("тангаж 20 град: матрицы одинаковы?", np.allclose(dcm_a, dcm_b))
    
        # Случай 2: тангаж ровно +90 град — крен и рыскание «схлопываются»:
        # ориентация зависит только от разности (крен - рыскание), здесь 20 град.
        dcm_c, quat_c = orientation_case(30.0, 90.0, 10.0)
        dcm_d, quat_d = orientation_case(50.0, 90.0, 30.0)
        print("тангаж 90 град: матрицы одинаковы?", np.allclose(dcm_c, dcm_d))
        print("тангаж 90 град: кватернионы одинаковы?", np.allclose(quat_c, quat_d))
        print("округлённая матрица вертикальной ориентации:")
        print(np.round(dcm_c, 4))

Задача 14.5. Тесты ориентации

Файл практики: k14_task5_test_attitude.py.

Создайте файл тестов: два опорных случая поворота, четыре проверки свойств DCM (согласование с матрицей рыскания, ортонормированность, определитель, обратное транспонирование), три проверки кватерниона (норма, круговой перевод, единичный кватернион) и один тест gimbal lock — всего десять тестов с допусками np.allclose() и pytest.approx.

Ожидаемый результат. Команда python -m pytest k14_task5_test_attitude.py -v показывает десять строк PASSED и итог «10 passed».

  • Полный текст файла k14_task5_test_attitude.py

    Скопируйте приведённое ниже содержимое в файл code/M01_python/k14_task5_test_attitude.py в проекте PyCharm (виртуальное окружение) и запустите тесты командой python -m pytest k14_task5_test_attitude.py -v из каталога code/M01_python. Ниже приведён полный учебный текст файла; он совпадает с файлом в репозитории курса.

    # k14_task5_test_attitude.py
    # Тесты математики ориентации K14: повороты, DCM, кватернионы, gimbal lock.
    # Запуск из каталога code/M01_python:
    #   python -m pytest k14_task5_test_attitude.py -v
    
    import math
    
    import numpy as np
    import pytest
    
    from k14_task1_yaw_rotation import rotate_body_to_ned, yaw_rotation_matrix
    from k14_task2_euler_dcm import euler_to_dcm, ned_to_body_matrix
    from k14_task3_quaternion import (
        euler_to_quaternion,
        quaternion_norm,
        quaternion_to_euler,
    )
    
    
    # --- поворот только по рысканию --------------------------------------------
    
    
    def test_yaw_90_turns_north_to_east():
        v = rotate_body_to_ned((1.0, 0.0, 0.0), math.radians(90.0))
        assert np.allclose(v, (0.0, 1.0, 0.0))
    
    
    def test_yaw_identity_at_zero():
        assert np.allclose(yaw_rotation_matrix(0.0), np.eye(3))
    
    
    # --- полная матрица ориентации ----------------------------------------------
    
    
    def test_dcm_matches_yaw_matrix_when_level():
        yaw = math.radians(40.0)
        assert np.allclose(euler_to_dcm(0.0, 0.0, yaw), yaw_rotation_matrix(yaw))
    
    
    def test_dcm_is_orthonormal():
        matrix = euler_to_dcm(math.radians(10.0), math.radians(-5.0), math.radians(30.0))
        assert np.allclose(np.dot(matrix, matrix.T), np.eye(3))
    
    
    def test_dcm_determinant_is_one():
        matrix = euler_to_dcm(math.radians(10.0), math.radians(-5.0), math.radians(30.0))
        assert np.linalg.det(matrix) == pytest.approx(1.0)
    
    
    def test_transpose_is_inverse_rotation():
        roll, pitch, yaw = math.radians(10.0), math.radians(-5.0), math.radians(30.0)
        v_body = (1.0, 2.0, -3.0)
        forward = np.dot(euler_to_dcm(roll, pitch, yaw), v_body)
        back = np.dot(ned_to_body_matrix(roll, pitch, yaw), forward)
        assert np.allclose(back, v_body)
    
    
    # --- кватернионы -------------------------------------------------------------
    
    
    def test_quaternion_norm_is_one():
        q = euler_to_quaternion(math.radians(10.0), math.radians(-5.0), math.radians(30.0))
        assert quaternion_norm(q) == pytest.approx(1.0)
    
    
    def test_euler_quaternion_round_trip():
        angles = (math.radians(10.0), math.radians(-5.0), math.radians(30.0))
        q = euler_to_quaternion(*angles)
        back = quaternion_to_euler(q)
        assert back == pytest.approx(angles)
    
    
    def test_identity_quaternion_gives_zero_angles():
        assert quaternion_to_euler((1.0, 0.0, 0.0, 0.0)) == pytest.approx((0.0, 0.0, 0.0))
    
    
    # --- gimbal lock -------------------------------------------------------------
    
    
    def test_gimbal_lock_collapses_roll_and_yaw():
        first = euler_to_dcm(math.radians(30.0), math.radians(90.0), math.radians(10.0))
        second = euler_to_dcm(math.radians(50.0), math.radians(90.0), math.radians(30.0))
        assert np.allclose(first, second)

Задача 14.6. Прогоны демо и тестов (обязательно выполнить)

Выполните команды по порядку из каталога code/M01_python в терминале с активным виртуальным окружением.

# Каталог: code/M01_python (venv активен, numpy установлен на K13)
python k14_task1_yaw_rotation.py
python k14_task2_euler_dcm.py
python k14_task3_quaternion.py
python k14_task4_gimbal_lock_demo.py
python -m pytest k14_task5_test_attitude.py -v

Ожидаемый результат. Ключевые строки вывода демо (сверьте значения):

task1: курс  90.0 град: нос в NED [0. 1. 0.], правый борт в NED [-1.  0.  0.]

task2: определитель: 1.000000 (ожидается 1)
       произведение R @ R.T равно единичной матрице [[1, 0, 0], [0, 1, 0], [0, 0, 1]]

task3: углы (град): (10.0, -5.0, 30.0)
       кватернион (w, x, y, z): (0.9604, 0.0954, -0.0194, 0.2613)
       норма кватерниона: 1.000000 (ожидается 1)
       обратно в углы (град): (10.0, -5.0, 30.0)

task4: тангаж 20 град: матрицы одинаковы? False
       тангаж 90 град: матрицы одинаковы? True
       тангаж 90 град: кватернионы одинаковы? True

tests: 10 passed

Строка «-0.» в выводе матриц — отрицательный ноль, знакомый по уроку K13: математически это ноль, на сравнения np.allclose() он не влияет.

Задача 14.7. Сохранение в Git

Добавьте в репозиторий пять файлов урока и сделайте коммит.

# Из корня репозитория курса
git add code/M01_python/k14_task1_yaw_rotation.py code/M01_python/k14_task2_euler_dcm.py code/M01_python/k14_task3_quaternion.py code/M01_python/k14_task4_gimbal_lock_demo.py code/M01_python/k14_task5_test_attitude.py
git commit -m "K14: euler angles, DCM, quaternions, gimbal lock demo, 10 tests"

Ожидаемый результат. Команда git status показывает чистое рабочее дерево по файлам k14_*, в истории появился коммит урока K14.

↑ К оглавлению

13. Проверьте себя

  1. Что такое ориентация аппарата и почему без неё невозможен пересчёт векторов из body в NED?
  2. Вокруг каких осей связанной системы измеряются крен, тангаж и рыскание и как правило правой руки задаёт положительное направление?
  3. Почему важен порядок поворотов ZYX и что произойдёт, если применить повороты в другом порядке?
  4. Что делает матрица поворота DCM с вектором и откуда произошло название «матрица направляющих косинусов»?
  5. Какие три свойства матрицы поворота проверяют тесты и что означает каждое из них?
  6. Что такое gimbal lock, при каком тангаже он возникает и как это проявляется численно в нашей демонстрации?
  7. Что такое кватернион ориентации и какие два преимущества дают ему место внутри автопилота?
  8. Зачем в формуле извлечения тангажа нужен math.atan2() для крена и рыскания и защемление аргумента для math.asin()?
  9. Чем угловая скорость отличается от углов ориентации и в каких полях сообщения ATTITUDE приходит каждое из них?
  10. Что означает принцип «свою архитектуру изобретай, свою математику — нет» и какие четыре шага он предписывает?
↑ К оглавлению

14. Чек-лист самопроверки

  • Я создал все пять файлов практики в каталоге code/M01_python.
  • Я могу показать на себе оси крена, тангажа и рыскания и объяснить знаки через правило правой руки.
  • Демо задачи 14.1 показывает, что при курсе 90° «нос» становится восточным вектором.
  • Демо задачи 14.2 показывает единичный определитель и единичную матрицу в произведении R @ R.T.
  • Демо задачи 14.3 возвращает исходные углы после кругового перевода через кватернион.
  • Демо задачи 14.4 напечатало True при тангаже 90° — я могу объяснить, почему две разные тройки углов совпали.
  • Команда python -m pytest k14_task5_test_attitude.py -v завершается результатом 10 passed.
  • Я могу назвать два преимущества кватерниона перед углами Эйлера и одну причину хранить ссылки на источник формул.
  • Я сделал git commit с файлами урока K14.
Результат. Когда все пункты закрыты, напишите ментору: K14 чек-лист закрыт. Следующий урок — K15 «Геокоординаты: широта, долгота, расстояние и направление», завершающий модуль M2 «Математика дрона».
↑ К оглавлению

15. Что дальше

  • Предыдущий урок: K13 · Векторы и системы координат: body, NED, ENU
  • Текущий урок: K14 · Углы Эйлера, кватернионы и DCM
  • Следующий урок: K15 · Геокоординаты: lat/lon/alt, расстояние haversine, направление bearing
  • После K15 модуль M2 завершается, и курс переходит к модулю M3 «Протоколы и интерфейсы» (K16–K22) с гейтом G2 после лабораторной работы K22.
Стиль изложения. Текст следует голосу внимательного преподавателя. Кодовые сущности в прозе оформлены тегом code, формулы набраны в LaTeX-нотации \( ... \) и \[ ... \]. Вводный alert излагает мысль без клишированного заголовка. Служебные врезки про особенности публикации в шапке отсутствуют. Dual-code с подписью «Чистый код». Стандарт: docs/05_EDITOR_STYLE_GUIDE.md.
↑ К оглавлению

 

 

Понедельник, 19 октября 2026
k14. Математика дрона: углы Эйлера, кватернионы и DCM