k13. Математика дрона: векторы и системы координат — body, NED, ENU
UAV Python Pro · Конспект K13 · Модуль M2. Математика дрона: векторы и системы координат — body, NED, ENU · Редакция 1.0 · После CHECKPOINT-G1
Модуль M1 закрыт, гейт G1 сдан: вы умеете собрать CLI-программу с конфигурацией, состоянием, логированием и тестами. С этого урока начинается модуль M2 «Математика дрона». Телеметрия автопилота и команды ему — это числа, но каждое число живёт в своей системе координат и без соглашения об осях смысла не имеет. Поле z, равное минус пяти, в одной системе означает «пять метров над стартовой точкой», а в другой — «пять метров под ней». На этом уроке вы освоите векторы и три главные системы координат беспилотника: связанную систему body, земную NED и земную ENU, — а также научитесь переводить векторы между ними и проверять результаты тестами.
Одна и та же физическая величина — например, скорость аппарата — записывается разными наборами чисел в зависимости от того, в какой системе координат ведётся отсчёт. Система координат фиксирует направление осей относительно выбранного объекта: относительно Земли (NED и ENU) или относительно корпуса аппарата (body). Сообщения MAVLink всегда указывают свою систему координат, и задача разработчика — никогда их не путать: перепутанный знак или переставленные оси в команде скорости напрямую превращаются в ошибку траектории полёта.
Содержание
- 1. Цели урока
- 2. Словарь урока
- 3. Зачем программисту БПЛА системы координат
- 4. Вектор, скаляр и операции с numpy
- 5. Системы координат и правая тройка
- 6. NED и ENU: две «земные» системы
- 7. Связанная система body: оси на корпусе
- 8. Скорость в NED и курс аппарата
- 9. Скалярное произведение и проекция: ветер
- 10. Тесты математики: pytest.approx
- 11. Типичные ошибки
- 12. Практика
- 13. Проверьте себя
- 14. Чек-лист самопроверки
- 15. Что дальше
1. Цели урока
После изучения этого урока вы будете уметь выполнять следующие действия.
- Вы установите библиотеку
numpyв виртуальное окружение и выполните базовые операции над векторами: сложение, вычитание, умножение на число, вычисление длины и нормирование. - Вы объясните, что такое система координат (фрейм), и проверите тройку осей на «правость» с помощью правила правой руки.
- Вы назовёте оси систем
body,NEDиENUи скажете, какая экосистема какую систему использует. - Вы переведёте вектор из NED в ENU и обратно с помощью функций
ned_to_enu()иenu_to_ned(). - Вы разложите скорость на компоненты NED по модулю скорости и курсу с помощью
math.cos,math.sinиmath.radians. - Вы вычислите скалярное произведение вектора ветра и направления полёта и истолкуете его знак как попутный или встречный ветер.
- Вы напишете тесты математических функций с допусками
pytest.approxиnp.allcloseи объясните, почему точное сравнение дробных чисел ненадёжно.
pymavlink будет читать позицию и скорость в NED, угловые скорости в связанной системе body и отправлять команды скорости снова в NED. Математика этого урока — не академическое упражнение, а ежедневный рабочий инструмент: без неё сообщения телеметрии остаются непонятными наборами чисел.2. Словарь урока
| Термин | Простыми словами |
|---|---|
| Скаляр | Величина, которую полностью задаёт одно число: напряжение батареи, температура, масса. |
| Вектор | Величина, у которой есть и числовое значение, и направление: скорость, перемещение, ветер. |
| Система координат (фрейм) | Договорённость о начале отсчёта и о направлениях трёх осей, относительно которых записываются координаты вектора. |
| Правая тройка | Согласованная ориентация осей: если согнуть пальцы правой руки от X к Y, большой палец укажет вдоль Z. |
| Body (связанная система) | Оси жёстко связаны с корпусом аппарата: X — вперёд, Y — вправо, Z — вниз. Поворачивается вместе с дроном. |
| NED | Земная система: X — север (North), Y — восток (East), Z — вниз (Down). Стандарт ArduPilot и PX4. |
| ENU | Земная система: X — восток (East), Y — север (North), Z — вверх (Up). Часто встречается в ROS. |
| Норма вектора | Длина вектора: корень из суммы квадратов компонент. |
| Единичный вектор | Вектор длины один: задаёт чистое направление без величины. |
| Курс (heading, yaw) | Угол между направлением на север и «носом» аппарата, измеренный по часовой стрелке в градусах. |
| Скалярное произведение | Операция над двумя векторами, результат которой — одно число; показывает, насколько векторы «сонаправлены». |
| Проекция | Составляющая одного вектора вдоль направления другого; скалярное произведение с единичным вектором направления. |
| numpy | Библиотека численных вычислений для Python: массивы и готовые векторные операции, отраслевой стандарт. |
| pytest.approx | Средство pytest для сравнения дробных чисел с допуском вместо точного равенства. |
3. Зачем программисту БПЛА системы координат
Начнём с конкретной ситуации. Бортовой компьютер получил число: вертикальная составляющая позиции равна минус пять метров. Само по себе это число ничего не утверждает. Если оно записано в системе NED, где ось Z направлена вниз, то минус пять означает «пять метров выше точки старта». Если то же число записано в системе ENU, где ось Z направлена вверх, то оно означает «пять метров ниже точки старта». Одно и то же число — два противоположных физических смысла. Различает их только договорённость о системе координат.
Цена такой путаницы в нашей отрасли высока. Если наземная станция отправит автопилоту команду скорости, в которой оси перепутаны местами, аппарат полетит на восток вместо севера; если перепутан знак вертикали — начнёт снижаться вместо набора высоты. В симуляторе SITL ошибка обернётся падением виртуального аппарата и строкой в журнале; на реальном беспилотном летательном аппарате — аварией. Поэтому у разработчиков есть простая дисциплина: каждое имя переменной и каждый комментарий обязаны называть систему координат явно, например v_ned или velocity_enu, а числа без указания системы считаются подозрительными.
Модуль M2 даёт минимальный математический набор, которого достаточно для уверенной работы с телеметрией и командами. Этот урок знакомит с векторами и системами координат. Следующий урок K14 объяснит, как углы ориентации (крен, тангаж, рыскание) связывают связанную систему аппарата с земной системой, и что такое кватернионы. Урок K15 переведёт всё это в географические координаты: широта, долгота, расстояние между точками и направление на точку.
↑ К оглавлению4. Вектор, скаляр и операции с numpy
Скаляр — это величина, которую полностью задаёт одно число: напряжение батареи 14,8 В, температура 23 °C, масса 1,2 кг. Вектор — величина, у которой кроме значения есть направление: скорость аппарата, перемещение из одной точки в другую, вектор ветра. Вектор записывают упорядоченным набором из трёх чисел — компонент по осям:
\[ \vec{v} = (v_x,\; v_y,\; v_z) \]
Словами: вектор — это тройка чисел, но не любая, а договорённая: первое число относится к оси X, второе к оси Y, третье к оси Z выбранной системы координат. Сами по себе операции над векторами не требуют выбора системы: векторы можно складывать (два последовательных перемещения дают суммарное), вычитать (разность позиций даёт перемещение «из B в A» и наоборот) и умножать на число (растягивать или сжимать длину, не меняя направления).
Норма вектора (его длина, или модуль) вычисляется по теореме Пифагора, обобщённой на три измерения:
\[ \|\vec{v}\| = \sqrt{v_x^2 + v_y^2 + v_z^2} \]
Словами: длина вектора равна квадратному корню из суммы квадратов его компонент. Если вектор поделить на собственную длину, получится единичный вектор — вектор длины ровно один, который хранит только направление. Единичные векторы нужны постоянно: например, чтобы сделать шаг фиксированной длины вдоль заданного направления.
Вычислять всё это вручную несложно, но в отрасли принят инструмент numpy — библиотека численных вычислений для Python. Она добавляет тип «массив чисел» и готовые операции: сложение массивов, умножение на число, вычисление нормы, скалярное произведение. numpy устанавливается в виртуальное окружение привычной командой из урока K06:
python -m pip install numpy
Чистый код
# Пример: перемещение между точками, длина и единичный вектор
import numpy as np
start = (0.0, 0.0, -5.0) # точка A в NED: 5 м над началом координат
end = (30.0, 40.0, -5.0) # точка B: 30 м север, 40 м восток, та же высота
shift = np.asarray(end, dtype=float) - np.asarray(start, dtype=float)
length = np.linalg.norm(shift)
direction = shift / length
step = direction * 10.0
- Построчный разбор
Ниже приведён тот же фрагмент с пояснением после каждой смысловой строки. Строки, которые начинаются с последовательности символов «# →», содержат комментарий преподавателя. Эти строки не являются обязательной частью программы.
import numpy as np # → Общепринятое короткое имя библиотеки: np. start = (0.0, 0.0, -5.0) # точка A в NED: 5 м над началом координат end = (30.0, 40.0, -5.0) # точка B: 30 м север, 40 м восток, та же высота # → Координаты в NED: ось Z направлена вниз, # → поэтому «выше старта» записывается отрицательным числом. shift = np.asarray(end, dtype=float) - np.asarray(start, dtype=float) # → np.asarray превращает кортеж в массив numpy с дробными числами. # → Вычитание массивов происходит покомпонентно: # → получается вектор перемещения из A в B: (30, 40, 0). length = np.linalg.norm(shift) # → Готовая функция нормы: корень из суммы квадратов компонент. # → Для (30, 40, 0) длина равна 50 — египетский треугольник 3-4-5, # → умноженный на десять. direction = shift / length # → Деление массива на число тоже покомпонентное: # → получился единичный вектор (0.6, 0.8, 0.0) — чистое направление. step = direction * 10.0 # → Умножение единичного вектора на 10 даёт перемещение # → длиной ровно 10 метров вдоль того же направления: (6, 8, 0).
5. Системы координат и правая тройка
Система координат (по-английски — frame, «рамка отсчёта») — это договорённость из двух частей: где находится начало отсчёта и куда направлены три оси. Один и тот же физический вектор в разных системах получает разные координаты. Бытовая аналогия: инструкцию «пройти два квартала вперёд и один направо» вы привязываете к себе и своему направлению взгляда, а инструкцию «два квартала на север и один на восток» — к карте города. Оба описания верны, но пересчитать одно в другое можно, только зная, куда вы смотрите. Аналогия заканчивается там, где начинается строгость: в математике правило пересчёта между системами известно точно и выражается формулами.
Оси трёхмерной системы принято ориентировать согласованно — в правую тройку. Проверка выполняется правилом правой руки:
Правило правой руки: согните пальцы правой руки от оси X
к оси Y по короткой дуге — отставленный большой палец
укажет направление оси Z.
NED: X — север, Y — восток ⇒ Z — вниз
ENU: X — восток, Y — север ⇒ Z — вверх
body: X — вперёд, Y — вправо ⇒ Z — вниз
Проверьте NED самостоятельно: положите правую руку над картой, согните пальцы от севера к востоку — по часовой стрелке, если смотреть сверху, — и большой палец укажет вниз, к земле. Именно так устроены все три системы этого урока. Правая тройка — не украшение, а защита от ошибок: если одна ось случайно «отразится», система станет левой, знак углов и вращений изменится на противоположный, и расчёты ориентации начнут врать.
Соберём три главные системы курса в одну таблицу.
| Система | Ось X | Ось Y | Ось Z | Кто использует |
|---|---|---|---|---|
| body (связанная) | вперёд, к «носу» | вправо по борту | вниз, к «животу» | Датчики на борту: акселерометр, гироскоп; угловые скорости в MAVLink |
| NED | север (North) | восток (East) | вниз (Down) | ArduPilot и PX4: позиция, скорость, ускорение локально |
| ENU | восток (East) | север (North) | вверх (Up) | ROS и исследовательские стеки, часть геоинформационных инструментов |
6. NED и ENU: две «земные» системы
NED и ENU описывают один и тот же мир — окрестность точки старта относительно сторон света, — но оси у них направлены по-разному. Переход между ними выражается простым правилом: первая и вторая компоненты меняются местами, а третья меняет знак.
\[ (n,\; e,\; d)_{\mathrm{NED}} \;\mapsto\; (e,\; n,\; -d)_{\mathrm{ENU}} \]
Словами: вектор «30 метров на север, 40 на восток, 5 вниз» в NED записывается как (30, 40, 5), а в ENU — как (40, 30, −5): восток стал первой компонентой, север второй, а «вниз» превратилось в «минус вверх». Обратите внимание на важное свойство: это преобразование симметрично. Обратный перевод из ENU в NED выполняется той же формулой — обмен местами и смена знака третьей компоненты. Применив правило дважды, вы получите исходный вектор; тесты практики проверяют именно этот «круговой» возврат.
Чистый код
# Пример: перевод векторов между NED и ENU
def ned_to_enu(v_ned):
n, e, d = float(v_ned[0]), float(v_ned[1]), float(v_ned[2])
return (e, n, -d)
def enu_to_ned(v_enu):
x, y, z = float(v_enu[0]), float(v_enu[1]), float(v_enu[2])
return (y, x, -z)
- Построчный разбор
Ниже приведён тот же фрагмент с пояснением после каждой смысловой строки. Строки, которые начинаются с последовательности символов «# →», содержат комментарий преподавателя. Эти строки не являются обязательной частью программы.
def ned_to_enu(v_ned): # → Функция принимает вектор в NED: кортеж, список или массив из трёх чисел. n, e, d = float(v_ned[0]), float(v_ned[1]), float(v_ned[2]) # → Распаковываем компоненты в именованные переменные и приводим к float: # → имена n, e, d напоминают о смысле каждой компоненты. return (e, n, -d) # → Правила перевода: восток становится первой компонентой ENU, # → север — второй, а «вниз» меняет знак на «вверх». def enu_to_ned(v_enu): # → Обратный перевод устроен симметрично. x, y, z = float(v_enu[0]), float(v_enu[1]), float(v_enu[2]) # → В ENU первая компонента — восток, вторая — север, третья — вверх. return (y, x, -z) # → Север (y) возвращается на первое место NED, # → восток (x) — на второе, «вверх» меняет знак на «вниз».
-0.0 == 0.0 даёт True, и тесты проходят. К разделу 10 вы узнаете, почему дробная арифметика компьютера вообще ведёт себя подобным образом.7. Связанная система body: оси на корпусе
Связанная система координат (body frame) отличается от земных принципиальным свойством: её оси жёстко привязаны к корпусу аппарата и вращаются вместе с ним. Ось X всегда смотрит вперёд, к «носу» — у мультиротора это направление переднего луча; ось Y — вправо по борту; ось Z — вниз, к «животу». Когда дрон наклоняется, «вперёд» наклоняется вместе с ним: вектор тяги моторов живёт именно в связанной системе, потому что моторы прикручены к раме, а не к планете.
Связанная система — это мир бортовых датчиков. Акселерометр измеряет ускорение вдоль осей корпуса, гироскоп — угловые скорости вращения вокруг этих осей. Оба прибора физически установлены на плате и «не знают», куда аппарат смотрит относительно севера. Поэтому в телеметрии MAVLink угловые скорости приходят в body-системе, а позиция и скорость относительно земли — в NED; подробнее о сообщениях мы поговорим в модуле M5.
Главный вопрос, который отсюда следует: как пересчитать вектор из связанной системы в земную и обратно? Ответ требует знания ориентации аппарата — углов крена (roll), тангажа (pitch) и рыскания (yaw), которые показывают, как именно повёрнут корпус относительно Земли. Полный пересчёт — тема следующего урока K14. Здесь зафиксируем два простых случая, которые понятны уже сейчас.
- Аппарат стоит на земле без наклона, «нос» смотрит на север. Связанная система совпадает с NED: вперёд — это север, вправо — восток, вниз — вниз.
- Аппарат без наклона повёрнул «нос» на восток (курс 90°). Теперь X связанной системы смотрит вдоль Y системы NED: «вперёд» стало «на восток». Пересчёт вектора сводится к повороту вокруг вертикальной оси на угол курса — ровно эту операцию выполняет раздел 8.
8. Скорость в NED и курс аппарата
Курс (heading, или рыскание yaw) — это угол между направлением на север и направлением «носа» аппарата, измеренный по часовой стрелке, если смотреть сверху. Курс 0° — аппарат смотрит на север, 90° — на восток, 180° — на юг, 270° — на запад. Именно в таком виде — градусы от севера по часовой стрелке — курс показывает компас и именно так его отдаёт наземная станция Mission Planner.
Пусть аппарат летит горизонтально со скоростью \(v\) метров в секунду по курсу \(\psi\). Разложение вектора скорости на компоненты NED задаётся школьными синусом и косинусом:
\[ v_N = v \cos\psi, \qquad v_E = v \sin\psi, \qquad v_D = 0 \]
Словами: северная составляющая скорости равна модулю скорости, умноженному на косинус курса, а восточная — на синус курса. Косинус даёт «долю движения вдоль севера», синус — «долю движения вдоль востока»; при курсе 0° весь полёт идёт на север, при 90° — на восток. Вертикальная составляющая в горизонтальном полёте равна нулю.
север (v_north = v · cos ψ)
↑
│ ╱ направление полёта
│ ╱ длиной v
│ ╱
│╱ ψ — курс от севера
│ по часовой стрелке
└────────→ восток (v_east = v · sin ψ)
Остаётся техническая деталь: тригонометрические функции Python работают не с градусами, а с радианами. Радиан — угловая мера, в которой полный оборот равен не 360°, а \(2\pi\) радиан; прямой угол — это \(\pi/2\) радиан, то есть примерно 1,5708. Переводит градусы в радианы функция math.radians(). Забыть этот перевод — одна из самых частых математических ошибок новичка: math.cos(90) вернёт не ноль, потому что 90 здесь — радианы, а не градусы.
Чистый код
# Пример: скорость в NED по модулю и курсу
import math
def heading_to_velocity_ned(speed_ms: float, heading_deg: float) -> tuple:
if speed_ms < 0:
raise ValueError(f"модуль скорости не может быть отрицательным: {speed_ms}")
yaw_rad = math.radians(heading_deg % 360.0)
v_north = speed_ms * math.cos(yaw_rad)
v_east = speed_ms * math.sin(yaw_rad)
return (v_north, v_east, 0.0)
- Построчный разбор
Ниже приведён тот же фрагмент с пояснением после каждой смысловой строки. Строки, которые начинаются с последовательности символов «# →», содержат комментарий преподавателя. Эти строки не являются обязательной частью программы.
import math # → Стандартный математический модуль Python: установка не требуется. def heading_to_velocity_ned(speed_ms: float, heading_deg: float) -> tuple: # → Вход: модуль скорости (м/с) и курс (градусы). Выход: кортеж из трёх чисел NED. if speed_ms < 0: raise ValueError(f"модуль скорости не может быть отрицательным: {speed_ms}") # → Модуль скорости — длина вектора, а длина не бывает отрицательной. # → Невозможный вход отклоняется исключением сразу (приём урока K08). yaw_rad = math.radians(heading_deg % 360.0) # → Остаток от деления на 360 приводит любой курс к диапазону 0–360: # → курс 450° означает то же, что 90°. Затем градусы переводятся в радианы. v_north = speed_ms * math.cos(yaw_rad) # → Косинус курса — доля скорости, направленная на север. v_east = speed_ms * math.sin(yaw_rad) # → Синус курса — доля скорости, направленная на восток. return (v_north, v_east, 0.0) # → Третья компонента NED — вертикальная; в горизонтальном полёте она нулевая.
Проверим формулу на опорных направлениях: при скорости 10 м/с должны получаться «круглые» числа.
| Курс, ° | Куда летит | v_north, м/с | v_east, м/с |
|---|---|---|---|
| 0 | на север | 10.00 | 0.00 |
| 45 | на северо-восток | 7.07 | 7.07 |
| 90 | на восток | 0.00 | 10.00 |
| 180 | на юг | -10.00 | 0.00 |
| 270 | на запад | 0.00 | -10.00 |
heading_to_velocity_ned() фактически поворачивает единичный вектор «вперёд» связанной системы вокруг вертикальной оси на угол курса. Это частный случай полного пересчёта body → NED, в котором участвуют ещё крен и тангаж; полный случай — тема следующего урока.9. Скалярное произведение и проекция: ветер
Вторая операция, которая нужна в работе постоянно, — скалярное произведение двух векторов. Результат этой операции — одно число (поэтому она и «скалярная»), которое показывает, насколько два вектора сонаправлены. Алгебраически скалярное произведение равно сумме произведений соответствующих компонент:
\[ \vec{a} \cdot \vec{b} = a_x b_x + a_y b_y + a_z b_z \]
Геометрически то же самое число равно произведению длин векторов на косинус угла \(\theta\) между ними:
\[ \vec{a} \cdot \vec{b} = \|\vec{a}\| \, \|\vec{b}\| \cos\theta \]
Словами и по знакам: если векторы смотрят примерно в одну сторону (угол острый), произведение положительно; если в противоположные (угол тупой) — отрицательно; если перпендикулярны — равно нулю, потому что косинус прямого угла равен нулю. Отдельно отметим частный случай: скалярное произведение вектора на единичный вектор направления даёт проекцию — составляющую вектора вдоль этого направления, со знаком.
Прикладная задача этого раздела: летим по известному курсу, известен вектор ветра. Насколько ветер попутный или встречный? Договоримся о соглашении (и запишем его в комментарии к коду): вектор ветра указывает, куда воздух движется. Метеорологи, наоборот, называют ветер по направлению, откуда он дует: «северный ветер» дует с севера на юг, то есть его вектор направлен на юг. Перепутать эти два соглашения — классический способ получить знак «наоборот».
Чистый код
# Пример: проекция ветра на направление полёта
import numpy as np
def wind_along_track_ms(wind_ned, heading_deg: float) -> float:
track = np.asarray(heading_to_velocity_ned(1.0, heading_deg), dtype=float)
wind = np.asarray(wind_ned, dtype=float)
return float(np.dot(wind, track))
- Построчный разбор
Ниже приведён тот же фрагмент с пояснением после каждой смысловой строки. Строки, которые начинаются с последовательности символов «# →», содержат комментарий преподавателя. Эти строки не являются обязательной частью программы.
import numpy as np # → Скалярное произведение возьмём готовое из numpy. def wind_along_track_ms(wind_ned, heading_deg: float) -> float: # → Вход: вектор ветра в NED (куда дует, м/с) и курс полёта (градусы). track = np.asarray(heading_to_velocity_ned(1.0, heading_deg), dtype=float) # → Направление полёта как единичный вектор: функция из раздела 8 # → вызывается со скоростью 1.0, поэтому возвращается чистое направление. wind = np.asarray(wind_ned, dtype=float) # → Ветер приводим к массиву numpy, чтобы воспользоваться np.dot. return float(np.dot(wind, track)) # → np.dot — скалярное произведение: сумма произведений компонент. # → Результат больше нуля — ветер попутный, меньше нуля — встречный, # → ноль — чистый боковой ветер. float(...) превращает ответ numpy # → в обычный тип Python — так удобнее печатать и тестировать.
Разберём числа из демонстрации практики. Полёт на север (курс 0°), единичный вектор направления равен (1, 0, 0). Ветер (5, 0, 0) — воздух движется на север вместе с нами: произведение +5, попутный ветер. Ветер (−8, 0, 0) — воздух движется на юг, навстречу: произведение −8, встречный ветер. Ветер (0, 6, 0) — дует строго на восток, перпендикулярно курсу: произведение 0, чистая боковина, которая сносит аппарат в сторону, но не меняет путевую скорость.
10. Тесты математики: pytest.approx
Математические функции тестируются так же, как любые другие (урок K11), но с одной особенностью: дробные числа в компьютере нельзя сравнивать на точное равенство. Причина в двоичном представлении: многие десятичные дроби, привычные человеку, в двоичной системе становятся бесконечными, и компьютер хранит их округлёнными. Классический пример:
Чистый код
# Пример: почему точное сравнение дробных чисел подводит
print(0.1 + 0.2 == 0.3) # False
print(0.1 + 0.2) # 0.30000000000000004
import math
print(math.cos(math.radians(90.0))) # 6.123233995736766e-17, а не 0.0
- Построчный разбор
Ниже приведён тот же фрагмент с пояснением после каждой смысловой строки. Строки, которые начинаются с последовательности символов «# →», содержат комментарий преподавателя. Эти строки не являются обязательной частью программы.
print(0.1 + 0.2 == 0.3) # False # → Сумма 0.1 + 0.2 не равна 0.3 точно: обе дроби двоично бесконечны, # → и результат округляется с крошечной погрешностью. print(0.1 + 0.2) # 0.30000000000000004 # → Вот эта погрешность: семнадцатый знак после запятой. import math print(math.cos(math.radians(90.0))) # 6.123233995736766e-17, а не 0.0 # → Косинус прямого угла математически равен нулю, # → но вычислительный результат — очень маленькое число 6.1e-17. # → Запись e-17 означает «умножить на 10 в минус семнадцатой степени».
Отсюда правило для тестов математики: вместо точного равенства использовать сравнение с допуском. В pytest для этого служит pytest.approx — он считает числа равными, если они отличаются в пределах крошечной относительной погрешности (по умолчанию около одной миллионной). Для массивов numpy тот же смысл несёт функция np.allclose(). Важно понимать: допуск не ослабляет тест до бесполезности. Погрешность округления составляет единицы в триллионных долях, тогда как физическая ошибка, которую тест обязан ловить, — это перепутанный знак, обмен осей или лишний множитель; такие ошибки больше допуска на многие порядки.
Чистый код
# Пример: тесты математики с допусками
import numpy as np
import pytest
from k13_task2_ned_enu import enu_to_ned, ned_to_enu
from k13_task3_velocity_ned import heading_to_velocity_ned
def test_heading_east():
assert heading_to_velocity_ned(10.0, 90.0) == pytest.approx((0.0, 10.0, 0.0))
def test_round_trip_returns_original():
v = (30.0, 40.0, -5.0)
assert enu_to_ned(ned_to_enu(v)) == pytest.approx(v)
def test_displacement_and_distance():
shift = np.array([30.0, 40.0, 0.0])
assert np.allclose(shift, (30.0, 40.0, 0.0))
assert np.linalg.norm(shift) == pytest.approx(50.0)
- Построчный разбор
Ниже приведён тот же фрагмент с пояснением после каждой смысловой строки. Строки, которые начинаются с последовательности символов «# →», содержат комментарий преподавателя. Эти строки не являются обязательной частью программы.
import numpy as np import pytest from k13_task2_ned_enu import enu_to_ned, ned_to_enu from k13_task3_velocity_ned import heading_to_velocity_ned # → Тестируем функции из модулей практики: обычный импорт, как в K11. def test_heading_east(): assert heading_to_velocity_ned(10.0, 90.0) == pytest.approx((0.0, 10.0, 0.0)) # → Косинус 90° не равен нулю точно (6.1e-17), поэтому без approx тест бы упал. # → pytest.approx сравнивает кортежи покомпонентно с допуском. def test_round_trip_returns_original(): v = (30.0, 40.0, -5.0) assert enu_to_ned(ned_to_enu(v)) == pytest.approx(v) # → «Круговой» тест: перевод туда и обратно обязан вернуть исходный вектор. # → Так проверяют согласованность пары взаимно обратных функций. def test_displacement_and_distance(): shift = np.array([30.0, 40.0, 0.0]) assert np.allclose(shift, (30.0, 40.0, 0.0)) assert np.linalg.norm(shift) == pytest.approx(50.0) # → Для массивов numpy — свой инструмент допуска: np.allclose. # → Норма сравнивается как обычное число через pytest.approx.
11. Типичные ошибки
| Ошибка | Что происходит | Как следует рассуждать |
|---|---|---|
| Передаёте вектор из ENU в интерфейс, ожидающий NED, без перевода | Аппарат летит не туда: север путается с востоком, набор высоты — со снижением | Перед каждой командой спрашивайте себя: в какой системе эти числа? Имена переменных обязаны содержать систему: v_ned, v_enu |
| Привычка «вверх — это плюс» переносится на NED | Знак вертикали перевёрнут: минус пять в NED — это пять метров высоты, а не глубины | В NED высота над началом отсчёта равна минус z; проверяйте знак на простом примере |
Курс в градусах передан в math.cos без перевода |
Компоненты скорости получаются совершенно неверными | Тригонометрия Python работает в радианах: сначала math.radians(), потом cos/sin |
Точное сравнение дробных чисел в тестах (== 0.0) |
Тест падает из-за погрешности в семнадцатом знаке | Используйте pytest.approx для чисел и кортежей, np.allclose для массивов |
| Ветер «откуда дует» подставлен как вектор «куда дует» | Знак проекции перевёрнут: встречный ветер выглядит попутным | Зафиксируйте соглашение в комментарии и в имени; в нашем коде вектор ветра — куда дует |
numpy не установлен в виртуальном окружении |
ModuleNotFoundError: No module named 'numpy' |
Выполните python -m pip install numpy в том же venv, где запускаете код |
| Нормирование нулевого вектора | Деление на ноль и «мусорный» результат | Перед делением проверяйте норму: функция unit_vector() поднимает ValueError для нулевого вектора |
12. Практика
Практика урока состоит из пяти файлов: четыре демонстрационных модуля с функциями и один файл тестов. Порядок работы следующий: установите numpy, создайте файлы задач 13.1–13.5 в каталоге code/M01_python, выполните прогоны из задачи 13.6 и сохраните исходники в Git (задача 13.7). В карточках в аккордеонах лежит полный текст каждого файла; он совпадает с содержимым репозитория курса.
Задача 13.1. Векторы и numpy
Файл практики: k13_task1_vector_basics.py.
Создайте модуль базовых векторных операций на numpy: перемещение между двумя точками displacement_ned(), длина вектора distance_m() и единичный вектор unit_vector() с защитой от нулевого вектора через ValueError.
Ожидаемый результат. Запуск python k13_task1_vector_basics.py печатает перемещение (30, 40, 0) м, расстояние 50.00 м, единичный вектор (0.6, 0.8, 0) и десятиметровый шаг (6, 8, 0) м.
- Полный текст файла k13_task1_vector_basics.py
Скопируйте приведённое ниже содержимое в файл
code/M01_python/k13_task1_vector_basics.pyв проекте PyCharm (виртуальное окружение) и запустите его из каталогаcode/M01_python. Ниже приведён полный учебный текст файла; он совпадает с файлом в репозитории курса.# k13_task1_vector_basics.py # Цель: базовые операции над векторами с помощью numpy — # перемещение между двумя точками в NED, длина, единичный вектор. # Запуск из каталога code/M01_python: # python k13_task1_vector_basics.py import numpy as np def displacement_ned(start_m, end_m): """Вектор перемещения из начальной точки в конечную (обе в NED, метры).""" return np.asarray(end_m, dtype=float) - np.asarray(start_m, dtype=float) def distance_m(vector_m): """Длина вектора (модуль) в метрах — евклидова норма.""" return float(np.linalg.norm(vector_m)) def unit_vector(vector_m): """Единичный вектор: направление то же, длина равна единице. Для нулевого вектора поднимает ValueError: направление не определено. """ v = np.asarray(vector_m, dtype=float) length = np.linalg.norm(v) if length == 0.0: raise ValueError("нулевой вектор не имеет направления") return v / length if __name__ == "__main__": start = (0.0, 0.0, -5.0) # точка A: 5 метров над началом координат end = (30.0, 40.0, -5.0) # точка B: 30 м на север, 40 м на восток, та же высота shift = displacement_ned(start, end) print(f"перемещение NED: {shift} м") print(f"расстояние: {distance_m(shift):.2f} м") direction = unit_vector(shift) print(f"направление (единичный вектор): {np.round(direction, 3)}") step = direction * 10.0 print(f"шаг 10 метров вдоль направления: {np.round(step, 3)} м")
Задача 13.2. Перевод NED ↔ ENU
Файл практики: k13_task2_ned_enu.py.
Создайте модуль перевода между системами координат: функции ned_to_enu() и enu_to_ned() и демонстрацию на четырёх векторах — три осевых и один произвольный — с обратным переводом туда и обратно.
Ожидаемый результат. Демо печатает для произвольного вектора цепочку NED (30.0, 40.0, -5.0) → ENU (40.0, 30.0, 5.0) → обратно (30.0, 40.0, -5.0); осевые векторы показывают обмен местами и смену знака вертикали.
- Полный текст файла k13_task2_ned_enu.py
Скопируйте приведённое ниже содержимое в файл
code/M01_python/k13_task2_ned_enu.pyв проекте PyCharm (виртуальное окружение) и запустите его из каталогаcode/M01_python. Ниже приведён полный учебный текст файла; он совпадает с файлом в репозитории курса.# k13_task2_ned_enu.py # Цель: перевод векторов между системами координат NED и ENU — # обмен местами двух осей и смена знака вертикали. # Запуск из каталога code/M01_python: # python k13_task2_ned_enu.py def ned_to_enu(v_ned): """Перевести координаты вектора из системы NED в систему ENU. NED: (north, east, down); ENU: (east, north, up). Правило: первая и вторая компоненты меняются местами, третья меняет знак. """ n, e, d = float(v_ned[0]), float(v_ned[1]), float(v_ned[2]) return (e, n, -d) def enu_to_ned(v_enu): """Перевести координаты вектора из системы ENU в систему NED. Преобразование симметрично: тот же обмен местами и тот же знак, поэтому обратный перевод возвращает исходный вектор. """ x, y, z = float(v_enu[0]), float(v_enu[1]), float(v_enu[2]) return (y, x, -z) if __name__ == "__main__": samples = [ ("1 м на север", (1.0, 0.0, 0.0)), ("1 м на восток", (0.0, 1.0, 0.0)), ("1 м вниз", (0.0, 0.0, 1.0)), ("произвольный вектор", (30.0, 40.0, -5.0)), ] for title, v_ned in samples: v_enu = ned_to_enu(v_ned) back = enu_to_ned(v_enu) print(f"{title}: NED {v_ned} -> ENU {v_enu} -> обратно {back}")
Задача 13.3. Скорость по курсу
Файл практики: k13_task3_velocity_ned.py.
Создайте модуль разложения скорости: функция heading_to_velocity_ned() с переводом градусов в радианы через math.radians(), компонентами через math.cos() и math.sin() и защитой от отрицательного модуля скорости.
Ожидаемый результат. Демо печатает компоненты для курсов 0°, 45°, 90°, 180° и 270° при скорости 10 м/с; для курса 90° северная компонента равна 0.00, а восточная — 10.00 м/с.
- Полный текст файла k13_task3_velocity_ned.py
Скопируйте приведённое ниже содержимое в файл
code/M01_python/k13_task3_velocity_ned.pyв проекте PyCharm (виртуальное окружение) и запустите его из каталогаcode/M01_python. Ниже приведён полный учебный текст файла; он совпадает с файлом в репозитории курса.# k13_task3_velocity_ned.py # Цель: разложить горизонтальную скорость на компоненты NED # по курсу (heading), измеренному в градусах от севера по часовой стрелке. # Запуск из каталога code/M01_python: # python k13_task3_velocity_ned.py import math def heading_to_velocity_ned(speed_ms: float, heading_deg: float) -> tuple: """Вернуть вектор скорости в NED (м/с) по модулю скорости и курсу. Курс измеряется в градусах от направления на север по часовой стрелке: 0 — север, 90 — восток, 180 — юг, 270 — запад. Вертикальная компонента равна нулю: рассматриваем горизонтальный полёт. """ if speed_ms < 0: raise ValueError(f"модуль скорости не может быть отрицательным: {speed_ms}") yaw_rad = math.radians(heading_deg % 360.0) v_north = speed_ms * math.cos(yaw_rad) v_east = speed_ms * math.sin(yaw_rad) return (v_north, v_east, 0.0) if __name__ == "__main__": speed = 10.0 for heading in (0.0, 45.0, 90.0, 180.0, 270.0): v = heading_to_velocity_ned(speed, heading) print( f"курс {heading:5.1f} град: " f"v_north={v[0]:7.2f} м/с v_east={v[1]:7.2f} м/с v_down={v[2]:4.1f} м/с" )
Задача 13.4. Проекция ветра
Файл практики: k13_task4_wind_projection.py.
Создайте модуль скалярного произведения: функция wind_along_track_ms() на np.dot() и демонстрация четырёх ветровых ситуаций для полёта на север — попутный, встречный, чистый боковой и ветер под углом.
Ожидаемый результат. Демо печатает проекцию -8.00 м/с со словом «встречный» для южного ветра и +5.00 м/с со словом «попутный» для северного ветра.
- Полный текст файла k13_task4_wind_projection.py
Скопируйте приведённое ниже содержимое в файл
code/M01_python/k13_task4_wind_projection.pyв проекте PyCharm (виртуальное окружение) и запустите его из каталогаcode/M01_python. Ниже приведён полный учебный текст файла; он совпадает с файлом в репозитории курса.# k13_task4_wind_projection.py # Цель: скалярное произведение векторов на прикладном примере — # попутная или встречная составляющая ветра вдоль курса полёта. # Запуск из каталога code/M01_python: # python k13_task4_wind_projection.py import numpy as np from k13_task3_velocity_ned import heading_to_velocity_ned def wind_along_track_ms(wind_ned, heading_deg: float) -> float: """Проекция вектора ветра на направление полёта (м/с). wind_ned — вектор ветра в NED: куда ветер дует, а не откуда. Результат больше нуля — попутный ветер, меньше нуля — встречный, равен нулю — чистый боковой ветер. """ track = np.asarray(heading_to_velocity_ned(1.0, heading_deg), dtype=float) wind = np.asarray(wind_ned, dtype=float) return float(np.dot(wind, track)) if __name__ == "__main__": heading = 0.0 # летим на север cases = [ ("ветер дует на север (+5 м/с вдоль курса)", (5.0, 0.0, 0.0)), ("ветер дует на юг (8 м/с навстречу)", (-8.0, 0.0, 0.0)), ("ветер дует на восток (чистая боковина)", (0.0, 6.0, 0.0)), ("ветер под 45 град к курсу", (3.0, 3.0, 0.0)), ] for title, wind in cases: projection = wind_along_track_ms(wind, heading) if projection > 0: kind = "попутный" elif projection < 0: kind = "встречный" else: kind = "боковой, проекция равна нулю" print(f"{title}: проекция {projection:+.2f} м/с ({kind})")
Задача 13.5. Тесты математики
Файл практики: k13_task5_test_frames.py.
Создайте файл тестов: три проверки векторных операций, три проверки перевода NED/ENU, пять проверок разложения скорости (включая ValueError при отрицательной скорости и курс больше 360°) и три проверки проекции ветра — всего четырнадцать тестов с допусками pytest.approx и np.allclose().
Ожидаемый результат. Команда python -m pytest k13_task5_test_frames.py -v показывает четырнадцать строк PASSED и итог «14 passed».
- Полный текст файла k13_task5_test_frames.py
Скопируйте приведённое ниже содержимое в файл
code/M01_python/k13_task5_test_frames.pyв проекте PyCharm (виртуальное окружение) и запустите тесты командойpython -m pytest k13_task5_test_frames.py -vиз каталогаcode/M01_python. Ниже приведён полный учебный текст файла; он совпадает с файлом в репозитории курса.# k13_task5_test_frames.py # Тесты математических модулей K13: векторы, системы NED/ENU, курс, проекция ветра. # Запуск из каталога code/M01_python: # python -m pytest k13_task5_test_frames.py -v import numpy as np import pytest from k13_task1_vector_basics import displacement_ned, distance_m, unit_vector from k13_task2_ned_enu import enu_to_ned, ned_to_enu from k13_task3_velocity_ned import heading_to_velocity_ned from k13_task4_wind_projection import wind_along_track_ms # --- векторы и numpy ------------------------------------------------------ def test_displacement_and_distance(): shift = displacement_ned((0.0, 0.0, -5.0), (30.0, 40.0, -5.0)) assert np.allclose(shift, (30.0, 40.0, 0.0)) assert distance_m(shift) == pytest.approx(50.0) def test_unit_vector_length_is_one(): u = unit_vector((30.0, 40.0, 0.0)) assert np.linalg.norm(u) == pytest.approx(1.0) def test_unit_vector_rejects_zero(): with pytest.raises(ValueError): unit_vector((0.0, 0.0, 0.0)) # --- системы координат NED и ENU ------------------------------------------ def test_north_in_enu_points_to_y(): assert ned_to_enu((1.0, 0.0, 0.0)) == pytest.approx((0.0, 1.0, 0.0)) def test_down_becomes_minus_up(): assert ned_to_enu((0.0, 0.0, 1.0)) == pytest.approx((0.0, 0.0, -1.0)) def test_round_trip_returns_original(): v = (30.0, 40.0, -5.0) assert enu_to_ned(ned_to_enu(v)) == pytest.approx(v) # --- курс и скорость ------------------------------------------------------ def test_heading_north(): assert heading_to_velocity_ned(10.0, 0.0) == pytest.approx((10.0, 0.0, 0.0)) def test_heading_east(): assert heading_to_velocity_ned(10.0, 90.0) == pytest.approx((0.0, 10.0, 0.0)) def test_heading_45_degrees(): v = heading_to_velocity_ned(10.0, 45.0) assert v[0] == pytest.approx(7.0710678, rel=1e-6) assert v[1] == pytest.approx(7.0710678, rel=1e-6) def test_heading_accepts_over_360(): assert heading_to_velocity_ned(10.0, 450.0) == pytest.approx((0.0, 10.0, 0.0)) def test_negative_speed_raises(): with pytest.raises(ValueError): heading_to_velocity_ned(-1.0, 0.0) # --- проекция ветра ------------------------------------------------------- def test_headwind_is_negative(): assert wind_along_track_ms((-8.0, 0.0, 0.0), 0.0) == pytest.approx(-8.0) def test_tailwind_is_positive(): assert wind_along_track_ms((5.0, 0.0, 0.0), 0.0) == pytest.approx(5.0) def test_crosswind_projection_is_zero(): assert wind_along_track_ms((0.0, 6.0, 0.0), 0.0) == pytest.approx(0.0, abs=1e-9)
Задача 13.6. Прогоны демо и тестов (обязательно выполнить)
Выполните команды по порядку из каталога code/M01_python в терминале с активным виртуальным окружением.
# Каталог: code/M01_python (venv активен)
python -m pip install numpy
python k13_task1_vector_basics.py
python k13_task2_ned_enu.py
python k13_task3_velocity_ned.py
python k13_task4_wind_projection.py
python -m pytest k13_task5_test_frames.py -v
Ожидаемый результат. Ключевые строки вывода демо (сверьте числа):
task1: перемещение NED: [30. 40. 0.] м
расстояние: 50.00 м
направление (единичный вектор): [0.6 0.8 0. ]
шаг 10 метров вдоль направления: [6. 8. 0.] м
task2: 1 м на север: NED (1.0, 0.0, 0.0) -> ENU (0.0, 1.0, -0.0) -> обратно (1.0, 0.0, 0.0)
1 м вниз: NED (0.0, 0.0, 1.0) -> ENU (0.0, 0.0, -1.0) -> обратно (0.0, 0.0, 1.0)
произвольный вектор: NED (30.0, 40.0, -5.0) -> ENU (40.0, 30.0, 5.0) -> обратно (30.0, 40.0, -5.0)
task3: курс 0.0 град: v_north= 10.00 м/с v_east= 0.00 м/с v_down= 0.0 м/с
курс 45.0 град: v_north= 7.07 м/с v_east= 7.07 м/с v_down= 0.0 м/с
курс 90.0 град: v_north= 0.00 м/с v_east= 10.00 м/с v_down= 0.0 м/с
task4: ветер дует на север (+5 м/с вдоль курса): проекция +5.00 м/с (попутный)
ветер дует на юг (8 м/с навстречу): проекция -8.00 м/с (встречный)
ветер дует на восток (чистая боковина): проекция +0.00 м/с (боковой, проекция равна нулю)
tests: 14 passed
Значение «-0.0» и «+0.00» в выводе — отрицательный ноль, о котором рассказано в разделе 6; математически это ноль, и тесты проходят штатно.
Задача 13.7. Сохранение в Git
Добавьте в репозиторий пять файлов урока и сделайте коммит.
# Из корня репозитория курса
git add code/M01_python/k13_task1_vector_basics.py code/M01_python/k13_task2_ned_enu.py code/M01_python/k13_task3_velocity_ned.py code/M01_python/k13_task4_wind_projection.py code/M01_python/k13_task5_test_frames.py
git commit -m "K13: vectors, NED/ENU frames, heading decomposition, wind projection, 14 tests"
Ожидаемый результат. Команда git status показывает чистое рабочее дерево по файлам k13_*, в истории появился коммит урока K13.
13. Проверьте себя
- Чем вектор отличается от скаляра? Приведите по два примера из телеметрии аппарата.
- Что фиксирует система координат и почему одни и те же числа в разных системах означают разное?
- Как с помощью правила правой руки проверить, что тройка осей — правая? Проверьте так NED и ENU.
- Назовите оси систем
body,NEDиENUи скажите, какая экосистема какую систему использует. - Сформулируйте правило перевода вектора из NED в ENU. Почему обратный перевод выполняется той же формулой?
- Аппарат снижается со скоростью 2 м/с. Какой знак у вертикальной компоненты в NED и в ENU?
- Как измеряется курс? Какие компоненты скорости NED получаются при курсе 0° и при курсе 90°?
- Зачем нужен перевод градусов в радианы и какая функция Python его выполняет?
- Что означает знак скалярного произведения вектора ветра и единичного вектора направления полёта?
- Почему в тестах математических функций используют
pytest.approx, а не точное равенство? Ослабляет ли допуск тест?
14. Чек-лист самопроверки
- Я установил
numpyв виртуальное окружение командойpython -m pip install numpy. - Я создал все пять файлов практики в каталоге
code/M01_python. - Четыре демо-скрипта запускаются, и я могу объяснить каждую напечатанную строку.
- Я могу записать по памяти правило перевода NED → ENU и объяснить, почему оно симметрично.
- Я вывел компоненты скорости для курсов 0°, 90°, 180° и 270° без подсказки.
- Я объясняю знак проекции ветра как попутный, встречный или боковой.
- Команда
python -m pytest k13_task5_test_frames.py -vзавершается результатом 14 passed. - Я могу объяснить, почему точное сравнение дробных чисел ненадёжно и что такое отрицательный ноль.
- Я сделал git commit с файлами урока K13.
body и земной NED.15. Что дальше
- Предыдущий урок: K12 · Мини-проект CLI «Fake GCS» (гейт G1 сдан)
- Текущий урок: K13 · Векторы и системы координат: body, NED, ENU
- Следующий урок: K14 · Углы Эйлера, кватернионы и DCM
- Модуль M2 не имеет собственного гейта; следующий гейт G2 сдаётся после лабораторной работы K22 в модуле M3 «Протоколы и интерфейсы».