k15. Математика дрона: геокоординаты — lat/lon/alt, расстояние haversine, направление bearing
UAV Python Pro · Конспект K15 · Модуль M2. Математика дрона: геокоординаты — lat/lon/alt, расстояние haversine, направление bearing · Редакция 1.0 · После CHECKPOINT-K14
Модуль M2 выходит на финиш: на уроке K13 вы освоили векторы и системы координат, на уроке K14 — ориентацию аппарата. Последний элемент математического минимума — сама Земля. Миссии записываются таблицей точек с широтой и долготой, телеметрия возвращает позицию в тех же координатах, а границы безопасности задаются радиусом вокруг домашней точки. На этом уроке вы научитесь вычислять расстояние и направление между двумя географическими точками, смещать точку на заданное число метров в заданном направлении и строить простейшую геозону — всё каноническими формулами, покрытыми автоматическими тестами.
Географические координаты — это углы, а не метры: широта и долгота измеряются в градусах, причём один градус долготы соответствует разным расстояниям на разных широтах. Поэтому применять теорему Пифагора прямо к градусам нельзя — расстояние и направление на сфере вычисляются особыми формулами: гаверсинус для расстояния, формула начального азимута для направления, формула конечной точки для смещения. Мы берём эти формулы из классического справочника, реализуем их чистыми функциями и проверяем тестами — ровно тем способом, который вы отработали на двух предыдущих уроках.
Содержание
- 1. Цели урока
- 2. Словарь урока
- 3. Зачем программисту БПЛА геодезия
- 4. Широта, долгота, высота: WGS-84 интуитивно
- 5. Расстояние: большой круг и гаверсинус
- 6. Направление на точку: bearing
- 7. Смещение точки на N метров
- 8. Домашняя позиция и простейшая геозона
- 9. Тесты геодезического модуля
- 10. Типичные ошибки
- 11. Практика
- 12. Проверьте себя
- 13. Чек-лист самопроверки
- 14. Что дальше
1. Цели урока
После изучения этого урока вы будете уметь выполнять следующие действия.
- Вы объясните, что измеряют широта, долгота и высота, и почему координаты — это углы, а не метры.
- Вы вычислите расстояние между двумя географическими точками по формуле гаверсинуса через функцию
haversine_distance_m(). - Вы вычислите направление (bearing) из одной точки на другую через функцию
bearing_deg()и подадите результат вheading_to_velocity_ned()из урока K13. - Вы получите координаты точки, смещённой на заданное расстояние в заданном направлении, через функцию
destination_point(). - Вы реализуете простейшую геозону: проверка расстояния от домашней точки и потолка высоты через функцию
geofence_status(). - Вы напишете тесты геодезических функций трёх классов: опорные значения, свойства и согласованность цепочки «смещение → расстояние → направление».
- Вы завершите модуль M2 «Математика дрона» и подготовитесь к модулю M3 «Протоколы и интерфейсы».
GLOBAL_POSITION_INT приносит широту, долготу и высоту аппарата; файл миссии представляет собой таблицу точек в тех же координатах; логика сопровождения цели в модуле M11 будет непрерывно вычислять расстояние и направление до объекта. Три функции этого урока — рабочий инструмент на все последующие модули курса.2. Словарь урока
| Термин | Простыми словами |
|---|---|
| Широта (latitude, lat) | Угол к северу или югу от экватора: от −90° до +90°. |
| Долгота (longitude, lon) | Угол к востоку или западу от Гринвичского меридиана: от −180° до +180°. |
| WGS-84 | Всемирная геодезическая система 1984 года: модель формы Земли (эллипсоид) и система координат, в которой работают спутниковые приёмники GPS. |
| Высота над уровнем моря (MSL) | Высота, отсчитанная от среднего уровня моря. |
| Относительная высота | Высота над домашней точкой старта; в MAVLink — отдельное поле. |
| Большой круг | Окружность на сфере с центром в центре сферы: экватор и любой меридиан. Кратчайший путь между точками идёт по его дуге. |
| Гаверсинус (haversine) | Формула расстояния между двумя точками сферы по их широтам и долготам; численно устойчива на малых дистанциях. |
| Bearing (азимут, направление) | Угол от направления на север по часовой стрелке до направления на целевую точку; тот же компасный договор, что у курса heading. |
| Destination point | Конечная точка: координаты места, до которого нужно пройти заданное расстояние в заданном направлении. |
| Домашняя позиция (home) | Точка старта, которую автопилот запоминает и к которой возвращается по команде RTL (Return To Launch). |
| Геозона (geofence) | Виртуальная граница разрешённой зоны полёта; при выходе за границу система предпринимает защитное действие. |
| GLOBAL_POSITION_INT | Сообщение MAVLink с глобальной позицией: координаты в целых числах, умноженных на 10⁷, высоты в миллиметрах. |
3. Зачем программисту БПЛА геодезия
Векторы и ориентация из уроков K13–K14 живут в локальном мире: метры и секунды вокруг точки старта. Миссии и навигация живут в глобальном мире: широта, долгота и высота. Связка этих двух миров — постоянная работа программиста беспилотных систем. Вот четыре типичные задачи, которые решаются инструментами этого урока.
- Построение маршрута: миссия «квадрат со стороной 200 метров вокруг точки старта» требует вычислить координаты четырёх углов — это смещение точки на расстояние в заданном направлении.
- Чтение телеметрии: аппарат прислал позицию; чтобы понять, как далеко он от дома и в какую сторону движется, нужны расстояние и направление между двумя точками.
- Безопасность: простейшая геозона обязана на каждом витке цикла управления сравнивать расстояние от дома и высоту с разрешёнными пределами.
- Сопровождение цели: камера видит объект, его координаты оценены — чтобы навестись или следовать за ним, вычисляется направление и расстояние до цели (модуль M11).
Этот урок завершает модуль M2. Три модуля курса — M2 (математика), M3 (протоколы) и M5 (MAVLink) — образуют ядро профессии: математика говорит, что означают числа, протоколы — как числа передаются, MAVLink — какие именно числа летают между аппаратами и наземными станциями.
↑ К оглавлению4. Широта, долгота, высота: WGS-84 интуитивно
Широта (latitude, обозначается греческой буквой φ, «фи») — это угол между плоскостью экватора и направлением из центра Земли на точку. Широта равна +90° на Северном полюсе, 0° на экваторе и −90° на Южном полюсе. Долгота (longitude, обозначается λ, «лямбда») — угол между плоскостью Гринвичского меридиана и плоскостью меридиана точки: от −180° на запад до +180° на восток. Третья координата — высота в метрах.
Широта (lat) — угол от плоскости экватора:
+90 на Северном полюсе, 0 на экваторе, -90 на Южном.
Долгота (lon) — угол от Гринвичского меридиана:
0 в Гринвиче, до +180 на восток, до -180 на запад.
Меридианы сходятся у полюсов:
один градус широты — всегда примерно 111 км,
один градус долготы — 111 км только на экваторе,
а на широте 55.75 град — уже примерно 63 км.
Система отсчёта, в которой спутниковый приёмник выдаёт координаты, называется WGS-84 (World Geodetic System 1984). Она описывает Землю как эллипсоид — слегка сплюснутый шар: экваториальный радиус 6378,137 км, полярный 6356,752 км; описание системы доступно по адресу https://en.wikipedia.org/wiki/World_Geodetic_System. Для наших задач — дистанции от метров до десятков километров — Землю достаточно считать шаром со средним радиусом \(R = 6\,371\,000\) метров: погрешность такой модели составляет доли процента и заведомо меньше ошибки самого спутникового приёмника, которая измеряется метрами.
С высотой связана важная развилка: существуют высота над уровнем моря (MSL, mean sea level) и относительная высота над точкой старта. Если домашняя точка находится на холме высотой 150 метров над уровнем моря, то относительная высота 20 метров соответствует 170 метрам MSL. MAVLink разделяет эти величины: сообщение GLOBAL_POSITION_INT несёт поле alt (высота над уровнем моря) и поле relative_alt (высота над домом), оба в миллиметрах; координаты lat и lon передаются в том же сообщении целыми числами — градусами, умноженными на \(10^7\). Целые числа используются потому, что протокол передаёт поля фиксированного размера, а целое значение не теряет точность при передаче; подробный разбор сообщений — в модуле M5. Справочник сообщений: https://mavlink.io/en/messages/common.html.
Дисциплина единиц из уроков K13–K14 сохраняется полностью: формулы этого урока работают с радианами, поэтому на входе каждой функции градусы переводятся через math.radians(), а результаты возвращаются в градусах через math.degrees(). Точка в коде представляется структурой GeoPoint — это dataclass из урока K09 с именованными полями lat_deg и lon_deg. Имена полей защищают от классической ошибки: порядок «широта, долгота» и «долгота, широта» перепутать в кортеже очень легко, а в именованных полях — трудно.
GeoPoint и MAVLink начинаются с широты, а формат GeoJSON и некоторые веб-карты — с долготы. При стыковке с внешним API проверяйте порядок в документации, иначе точка «уедет» на другой конец света.5. Расстояние: большой круг и гаверсинус
Почему нельзя обойтись школьной теоремой Пифагора, применённой к разностям градусов? Потому что градусы — неодинаковые единицы длины. Один градус широты всегда соответствует примерно 111 километрам, а один градус долготы «сжимается» по мере движения к полюсам: его длина приблизительно равна \(111.32 \cdot \cos\varphi\) километров. На широте Москвы один градус долготы — это уже около 63 километров. Пифагор по градусам даст тем большую ошибку, чем сильнее широта места отличается от экватора и чем длиннее дистанция.
Правильная модель: кратчайший путь между двумя точками на сфере проходит по дуге большого круга — окружности, центр которой совпадает с центром сферы (экватор и любой меридиан — большие круги; параллель, кроме экватора, — нет). Расстояние по дуге большого круга вычисляет формула гаверсинуса. Канонический источник всех формул этого урока — классический справочник https://www.movable-type.co.uk/scripts/latlong.html. Пусть φ и λ — широта и долгота в радианах, индекс 1 — первая точка, индекс 2 — вторая, Δ — разность:
\[ a = \sin^2\!\left(\frac{\Delta\varphi}{2}\right) + \cos\varphi_1 \cdot \cos\varphi_2 \cdot \sin^2\!\left(\frac{\Delta\lambda}{2}\right) \]
\[ d = 2R \cdot \operatorname{atan2}\!\left(\sqrt{a},\ \sqrt{1-a}\right) \]
Словами: сначала вычисляется вспомогательная величина a — комбинация квадратов синусов половинных разностей координат, затем угловое расстояние через atan2 от двух корней, и наконец расстояние в метрах как произведение углового расстояния на радиус Земли. Именно эта форма, а не эквивалентная через арксинус, сохраняет точность на малых дистанциях — тех самых десятках и сотнях метров, на которых летает беспилотник.
Чистый код
# Пример: расстояние между двумя точками (фрагмент k15_task1_haversine.py)
import math
from dataclasses import dataclass
EARTH_RADIUS_M = 6371000.0
@dataclass
class GeoPoint:
lat_deg: float
lon_deg: float
def haversine_distance_m(p1: GeoPoint, p2: GeoPoint) -> float:
lat1, lon1 = math.radians(p1.lat_deg), math.radians(p1.lon_deg)
lat2, lon2 = math.radians(p2.lat_deg), math.radians(p2.lon_deg)
d_lat = lat2 - lat1
d_lon = lon2 - lon1
a = (
math.sin(d_lat / 2.0) ** 2
+ math.cos(lat1) * math.cos(lat2) * math.sin(d_lon / 2.0) ** 2
)
c = 2.0 * math.atan2(math.sqrt(a), math.sqrt(1.0 - a))
return EARTH_RADIUS_M * c
- Построчный разбор
Ниже приведён тот же фрагмент с пояснением после каждой смысловой строки. Строки, которые начинаются с последовательности символов «# →», содержат комментарий преподавателя. Эти строки не являются обязательной частью программы.
import math from dataclasses import dataclass # → Только стандартная библиотека: этот урок не ставит новых пакетов. EARTH_RADIUS_M = 6371000.0 # → Средний радиус Земли в метрах — одна константа на все формулы урока. # → Имя константы содержит единицу измерения: _M означает метры. @dataclass class GeoPoint: lat_deg: float lon_deg: float # → Dataclass из урока K09: именованные поля lat_deg и lon_deg # → исключают молчаливую путаницу порядка координат. # → Суффикс _deg фиксирует единицу: градусы, не радианы. def haversine_distance_m(p1: GeoPoint, p2: GeoPoint) -> float: # → Чистая функция: две точки на входе — расстояние в метрах на выходе. lat1, lon1 = math.radians(p1.lat_deg), math.radians(p1.lon_deg) lat2, lon2 = math.radians(p2.lat_deg), math.radians(p2.lon_deg) # → Перевод градусов в радианы на входе: вся тригонометрия ниже # → работает только с радианами (дисциплина уроков K13-K14). d_lat = lat2 - lat1 d_lon = lon2 - lon1 # → Разности координат. Периодичность синуса и косинуса делает # → формулу корректной даже при пересечении меридиана ±180 град. a = ( math.sin(d_lat / 2.0) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(d_lon / 2.0) ** 2 ) # → Величина a из формулы гаверсинуса: ** 2 означает квадрат. # → Косинусы широт «сжимают» вклад долготы — ровно та поправка, # → которой не хватало наивной теореме Пифагора по градусам. c = 2.0 * math.atan2(math.sqrt(a), math.sqrt(1.0 - a)) # → Угловое расстояние c в радианах: форма через atan2 # → численно устойчива на малых дистанциях. return EARTH_RADIUS_M * c # → Дуга = радиус × угол: расстояние в метрах.Python
Демонстрация практики печатает три контрольных числа: расстояние от центра Москвы до Балашихи — 22095 метров, расстояние одного градуса широты — 111195 метров (те самые «примерно 111 километров») и ноль для расстояния точки до самой себя. Отдельное полезное свойство: при чисто меридиональном смещении (долгота не меняется) гаверсинус сводится к точной формуле \(d = R \cdot \Delta\varphi\) — этот факт закрепляет тест.
↑ К оглавлению6. Направление на точку: bearing
Второй вопрос после «как далеко?» — «в какую сторону?». Bearing (начальный азимут) — это угол от направления на север до направления на целевую точку, отсчитанный по часовой стрелке: 0° — цель на севере, 90° — на востоке, 180° — на юге, 270° — на западе. Обратите внимание: это в точности тот же компасный договор, который использовал курс (heading) в уроке K13. Поэтому результат bearing_deg() можно напрямую подать в heading_to_velocity_ned() — и получить вектор скорости в NED, ведущий к цели. Так математика двух уроков собирается в одну цепочку.
\[ \theta = \operatorname{atan2}\bigl(\sin\Delta\lambda \cdot \cos\varphi_2,\;\; \cos\varphi_1 \cdot \sin\varphi_2 - \sin\varphi_1 \cdot \cos\varphi_2 \cdot \cos\Delta\lambda \bigr) \]
Чистый код
# Пример: направление из точки в точку (фрагмент k15_task2_bearing.py)
import math
from k15_task1_haversine import GeoPoint
def bearing_deg(p1: GeoPoint, p2: GeoPoint) -> float:
lat1, lon1 = math.radians(p1.lat_deg), math.radians(p1.lon_deg)
lat2, lon2 = math.radians(p2.lat_deg), math.radians(p2.lon_deg)
d_lon = lon2 - lon1
y = math.sin(d_lon) * math.cos(lat2)
x = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(d_lon)
return math.degrees(math.atan2(y, x)) % 360.0
- Построчный разбор
Ниже приведён тот же фрагмент с пояснением после каждой смысловой строки. Строки, которые начинаются с последовательности символов «# →», содержат комментарий преподавателя. Эти строки не являются обязательной частью программы.
import math from k15_task1_haversine import GeoPoint # → Тип точки импортируется из модуля расстояния: # → одна структура данных на весь урок, без дублирования. def bearing_deg(p1: GeoPoint, p2: GeoPoint) -> float: # → Из p1 смотрим на p2; результат — градусы от севера по часовой стрелке. lat1, lon1 = math.radians(p1.lat_deg), math.radians(p1.lon_deg) lat2, lon2 = math.radians(p2.lat_deg), math.radians(p2.lon_deg) d_lon = lon2 - lon1 # → Перевод в радианы и разность долгот — те же шаги, что в гаверсинусе. y = math.sin(d_lon) * math.cos(lat2) x = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(d_lon) # → Два выражения канонической формулы. Их отношение — тангенс # → искомого угла, но делить нельзя: x может оказаться нулём. return math.degrees(math.atan2(y, x)) % 360.0 # → atan2(y, x) из урока K14 возвращает угол в правильном квадранте, # → от -180 до +180 град; остаток от деления на 360 приводит # → ответ к компасному диапазону 0..360: -90 становится 270.Python
Два уточнения для честности картины. Первое: вдоль дуги большого круга направление постепенно меняется — формула возвращает начальный азимут. На дистанциях дрона (единицы километров) изменение составляет доли градуса и практики не касается. Второе: демонстрация печатает для цели строго на восток значение 89.96°, а не ровно 90°. Это не ошибка, а свойство сферы: дуга большого круга между двумя точками одной параллели слегка выгибается к полюсу, поэтому стартует чуть севернее чистого востока. Тест фиксирует направление с допуском и объясняет причину в комментарии.
↑ К оглавлению7. Смещение точки на N метров
Третья задача — обратная к расстоянию и направлению: даны исходная точка, направление и дистанция; найти координаты конечной точки. Именно она превращает математику в маршрут: «отойти на 150 метров на северо-восток от дома», «построить квадрат 200 × 200 метров вокруг точки съёмки», «держать круг радиусом 50 метров вокруг цели». Формула конечной точки (destination point) также берётся из канонического справочника. В ней появляется угловое расстояние \(\delta = d / R\) — дистанция в метрах, выраженная в радианах дуги:
\[ \begin{aligned} \delta &= d / R \\ \varphi_2 &= \arcsin\bigl( \sin\varphi_1 \cos\delta + \cos\varphi_1 \sin\delta \cos\theta \bigr) \\ \lambda_2 &= \lambda_1 + \operatorname{atan2}\bigl( \sin\theta \sin\delta \cos\varphi_1,\;\; \cos\delta - \sin\varphi_1 \sin\varphi_2 \bigr) \end{aligned} \]
Чистый код
# Пример: смещение точки (фрагмент k15_task3_destination.py)
import math
from k15_task1_haversine import EARTH_RADIUS_M, GeoPoint
def destination_point(origin: GeoPoint, bearing_target_deg: float, distance_m: float) -> GeoPoint:
lat1 = math.radians(origin.lat_deg)
lon1 = math.radians(origin.lon_deg)
theta = math.radians(bearing_target_deg)
delta = distance_m / EARTH_RADIUS_M
lat2 = math.asin(
math.sin(lat1) * math.cos(delta)
+ math.cos(lat1) * math.sin(delta) * math.cos(theta)
)
lon2 = lon1 + math.atan2(
math.sin(theta) * math.sin(delta) * math.cos(lat1),
math.cos(delta) - math.sin(lat1) * math.sin(lat2),
)
return GeoPoint(math.degrees(lat2), math.degrees(lon2))
- Построчный разбор
Ниже приведён тот же фрагмент с пояснением после каждой смысловой строки. Строки, которые начинаются с последовательности символов «# →», содержат комментарий преподавателя. Эти строки не являются обязательной частью программы.
import math from k15_task1_haversine import EARTH_RADIUS_M, GeoPoint # → Радиус Земли и тип точки берутся из первого модуля: # → все три формулы урока обязаны использовать одну константу. def destination_point(origin: GeoPoint, bearing_target_deg: float, distance_m: float) -> GeoPoint: # → Вход: исходная точка, направление в градусах, дистанция в метрах. # → Выход: новая точка — тоже GeoPoint, в градусах. lat1 = math.radians(origin.lat_deg) lon1 = math.radians(origin.lon_deg) theta = math.radians(bearing_target_deg) # → Все три угла переводятся в радианы до начала вычислений. delta = distance_m / EARTH_RADIUS_M # → Угловое расстояние: сколько радианов дуги «укладывается» # → в дистанцию. Сто метров — это примерно 1.57e-5 радиан. lat2 = math.asin( math.sin(lat1) * math.cos(delta) + math.cos(lat1) * math.sin(delta) * math.cos(theta) ) # → Широта конечной точки. Аргумент asin здесь математически # → ограничен диапазоном от -1 до 1 и состоит из слагаемых # → с запасом, поэтому защемление, как в K14, не требуется; # → но тесты обязаны это поведение фиксировать. lon2 = lon1 + math.atan2( math.sin(theta) * math.sin(delta) * math.cos(lat1), math.cos(delta) - math.sin(lat1) * math.sin(lat2), ) # → Долгота конечной точки: к исходной прибавляется приращение, # → вычисленное через atan2 в правильном квадранте. return GeoPoint(math.degrees(lat2), math.degrees(lon2)) # → Возврат в градусах: интерфейс функции говорит с человеком, # → радианы остаются внутренней кухней.Python
Как убедиться, что формула перенесена в код верно? Тем же приёмом, что и в уроке K14: не сверять числа глазами, а проверить согласованность цепочки. Если сместить точку на 300 метров по направлению 45°, то обратный пересчёт обязан вернуть дистанцию 300 метров (гаверсинус) и направление 45° (bearing). Демо практики печатает именно эту обратную проверку, а тесты фиксируют её с допусками pytest.approx.
8. Домашняя позиция и простейшая геозона
Домашняя позиция (home position, «дом») — точка, которую автопилот запоминает при первом уверенном определении позиции и при взведении моторов. К ней аппарат возвращается по команде RTL (Return To Launch — «возврат на точку старта»), она же служит началом отсчёта относительной высоты. В симуляторе SITL домашняя позиция создаётся при запуске; на реальном аппарате — на месте старта.
Геозона (geofence) — виртуальная граница, за которую аппарату вылетать запрещено. В ArduPilot существует полноценный механизм геозоны (geofence) с группой параметров, имена которых начинаются с FENCE_ (например, FENCE_ENABLE, FENCE_RADIUS, FENCE_ALT_MAX); настраивать их мы будем в модулях M4 и M6. Учебная версия повторяет логику простейшего случая: разрешённая зона — круг заданного радиуса вокруг домашней точки с потолком высоты. Функция проверки возвращает один из трёх статусов: "OK", "OUT_OF_RADIUS" или "ABOVE_CEILING". Настоящая система по результату проверки запускает защитное действие — возврат домой или посадку; этот механизм называется failsafe, и вы знакомы с ним со вводного урока K00.
Чистый код
# Пример: простейшая геозона (фрагмент k15_task4_geofence.py)
from k15_task1_haversine import GeoPoint, haversine_distance_m
def geofence_status(position: GeoPoint, alt_m: float, home: GeoPoint,
radius_m: float, max_alt_m: float) -> str:
distance_m = haversine_distance_m(home, position)
if distance_m > radius_m:
return "OUT_OF_RADIUS"
if alt_m > max_alt_m:
return "ABOVE_CEILING"
return "OK"
- Построчный разбор
Ниже приведён тот же фрагмент с пояснением после каждой смысловой строки. Строки, которые начинаются с последовательности символов «# →», содержат комментарий преподавателя. Эти строки не являются обязательной частью программы.
from k15_task1_haversine import GeoPoint, haversine_distance_m # → Геозона собирается из готовых кирпичей урока: точка и расстояние. def geofence_status(position: GeoPoint, alt_m: float, home: GeoPoint, radius_m: float, max_alt_m: float) -> str: # → Вход: текущая позиция, относительная высота, домашняя точка, # → разрешённый радиус и потолок высоты. Выход — строка-статус. distance_m = haversine_distance_m(home, position) # → Расстояние от дома вычисляется гаверсинусом из модуля задачи 15.1. if distance_m > radius_m: return "OUT_OF_RADIUS" # → Первая проверка: выход за горизонтальную границу. # → Порядок проверок зафиксирован и описан в docstring: # → сначала радиус, затем потолок. if alt_m > max_alt_m: return "ABOVE_CEILING" # → Вторая проверка: превышение потолка высоты. return "OK" # → Обе границы соблюдены — полёт в разрешённой зоне. # → Строковые статусы — та же идея меток OK/LOW/CRITICAL из K11.Python
geofence_status() — зародыш логики безопасности, которая будет расти вместе с курсом: в модуле M6 она встретится с настоящими параметрами FENCE_* автопилота, а в капстоун-проектах модуля M12 станет частью наземного сервиса, который следит за аппаратом во время миссии. Математика зоны (расстояние от дома) уже написана и протестирована вами.9. Тесты геодезического модуля
Тесты урока продолжают стратегию K14: вместо «сверить числа глазами» проверяются опорные значения, свойства функций и согласованность цепочки преобразований. Файл k15_task5_test_geo.py содержит двенадцать тестов трёх классов.
- Опорные значения: расстояние от точки до самой себя равно нулю; направление строго на север равно 0°, строго на юг — 180°.
- Свойства: расстояние симметрично (порядок точек не важен); смещение строго на север увеличивает широту и не меняет долготу; один градус широты равен \(R \cdot \Delta\varphi\) в радианах.
- Согласованность цепочки: сместили точку — гаверсинус вернул исходную дистанцию, bearing вернул исходное направление; статусы геозоны соответствуют трём сценариям нарушения и одному корректному.
Чистый код
# Пример: тесты свойств и согласованности (фрагмент k15_task5_test_geo.py)
import math
import pytest
from k15_task1_haversine import EARTH_RADIUS_M, GeoPoint, haversine_distance_m
from k15_task3_destination import destination_point
HOME = GeoPoint(55.7558, 37.6173)
def test_one_degree_latitude():
north = GeoPoint(HOME.lat_deg + 1.0, HOME.lon_deg)
expected = EARTH_RADIUS_M * math.radians(1.0)
assert haversine_distance_m(HOME, north) == pytest.approx(expected, rel=1e-9)
def test_destination_round_trip_distance():
target = destination_point(HOME, 45.0, 300.0)
assert haversine_distance_m(HOME, target) == pytest.approx(300.0, rel=1e-6)
- Построчный разбор
Ниже приведён тот же фрагмент с пояснением после каждой смысловой строки. Строки, которые начинаются с последовательности символов «# →», содержат комментарий преподавателя. Эти строки не являются обязательной частью программы.
import math import pytest from k15_task1_haversine import EARTH_RADIUS_M, GeoPoint, haversine_distance_m from k15_task3_destination import destination_point # → Тесты импортируют функции из модулей урока — схема K11: # → логика отдельно, проверки отдельно. HOME = GeoPoint(55.7558, 37.6173) # → Общая точка отсчёта тестов: координаты центра Москвы. def test_one_degree_latitude(): north = GeoPoint(HOME.lat_deg + 1.0, HOME.lon_deg) expected = EARTH_RADIUS_M * math.radians(1.0) assert haversine_distance_m(HOME, north) == pytest.approx(expected, rel=1e-9) # → Опорное значение, выведенное из математики: чистое меридиональное # → смещение сводит гаверсинус к точной формуле дуги R·Δφ. # → rel=1e-9: совпадение обязано быть почти машинно-точным. def test_destination_round_trip_distance(): target = destination_point(HOME, 45.0, 300.0) assert haversine_distance_m(HOME, target) == pytest.approx(300.0, rel=1e-6) # → Согласованность цепочки: сместились на 300 метров — # → обратное расстояние обязано вернуть 300 метров. # → Тест ловит перепутанные знаки и оси в обеих формулах сразу.Python
Отдельно прокомментируем выбор допусков. Для расстояний используется относительный допуск rel: он масштабируется вместе с величиной. Для направлений в градусах используется абсолютный допуск abs: например, проверка «на восток» написана как pytest.approx(90.0, abs=0.5), потому что истинное значение 89.96 — не ошибка кода, а физика большого круга, и тест обязан это учитывать, оставаясь при этом строгим к настоящим поломкам.
10. Типичные ошибки
| Ошибка | Что происходит | Как следует рассуждать |
|---|---|---|
| Градусы поданы в тригонометрию без перевода | Расстояния и направления неверны на порядки | math.radians() на входе каждой функции, math.degrees() на выходе к человеку |
| Теорема Пифагора по разностям градусов | Ошибка растёт с широтой и дистанцией: градус долготы «сжимается» к полюсам | Для дистанций любой длины используйте гаверсинус |
| Перепутан порядок координат: (lon, lat) вместо (lat, lon) | Точка «уезжает» в другую часть света | Структура GeoPoint с именованными полями; при стыковке с API читайте документацию — GeoJSON начинается с долготы |
| Высота над уровнем моря смешана с относительной | Ошибка в десятки и сотни метров: потолок геозоны срабатывает не там | В MAVLink это разные поля: alt и relative_alt; фиксируйте систему отсчёта в имени переменной |
| Ручные вычисления долготы при пересечении меридиана ±180° | Знаки и расстояния «прыгают» | Канонические формулы защищены периодичностью синуса и косинуса; не изобретайте плоскую арифметику долгот |
| Тест направления на восток с точным равенством 90.0 | Тест падает: истинное значение 89.96 из-за выгиба большого круга | Допуск abs=0.5 и комментарий о причине |
| Координаты MAVLink поделены не на тот масштаб | Позиция оказывается в тысячах раз больше ожидаемой | Поля lat и lon сообщения GLOBAL_POSITION_INT — градусы, умноженные на 10⁷; делите на 10⁷ (разбор в M5) |
11. Практика
Практика урока состоит из пяти файлов: четыре модуля геодезической математики и один файл тестов. Новые пакеты не потребуются: урок использует только стандартную библиотеку (math, dataclasses) и уже установленный pytest. Порядок работы: создайте файлы задач 15.1–15.5 в каталоге code/M01_python, выполните прогоны из задачи 15.6 и сохраните исходники в Git (задача 15.7). В карточках в аккордеонах лежит полный текст каждого файла; он совпадает с содержимым репозитория курса.
Задача 15.1. Расстояние (haversine)
Файл практики: k15_task1_haversine.py.
Создайте базовый модуль геодезии: структуру GeoPoint на dataclass, константу EARTH_RADIUS_M и функцию расстояния haversine_distance_m() с канонической формулой и ссылкой на источник в комментарии.
Ожидаемый результат. Демо печатает расстояние Москва → Балашиха 22095 м, один градус широты 111195 м и 0.00 м для расстояния точки до самой себя.
- Полный текст файла k15_task1_haversine.py
Скопируйте приведённое ниже содержимое в файл
code/M01_python/k15_task1_haversine.pyв проекте PyCharm (виртуальное окружение) и запустите его из каталогаcode/M01_python. Ниже приведён полный учебный текст файла; он совпадает с файлом в репозитории курса.# k15_task1_haversine.py # Цель: расстояние между двумя точками по широте и долготе — формула гаверсинуса. # Канонический источник формул: # https://www.movable-type.co.uk/scripts/latlong.html # Запуск из каталога code/M01_python: # python k15_task1_haversine.py import math from dataclasses import dataclass EARTH_RADIUS_M = 6371000.0 # средний радиус Земли, метры @dataclass class GeoPoint: """Географическая точка: широта и долгота в градусах. Dataclass намеренно хранит поля под именами lat_deg и lon_deg, чтобы порядок «широта, долгота» нельзя было перепутать молча. """ lat_deg: float lon_deg: float def haversine_distance_m(p1: GeoPoint, p2: GeoPoint) -> float: """Расстояние между двумя точками по дуге большого круга, метры.""" lat1, lon1 = math.radians(p1.lat_deg), math.radians(p1.lon_deg) lat2, lon2 = math.radians(p2.lat_deg), math.radians(p2.lon_deg) d_lat = lat2 - lat1 d_lon = lon2 - lon1 a = ( math.sin(d_lat / 2.0) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(d_lon / 2.0) ** 2 ) c = 2.0 * math.atan2(math.sqrt(a), math.sqrt(1.0 - a)) return EARTH_RADIUS_M * c if __name__ == "__main__": moscow = GeoPoint(55.7558, 37.6173) # Москва, центр balashikha = GeoPoint(55.8117, 37.9564) # Балашиха d = haversine_distance_m(moscow, balashikha) print(f"Москва -> Балашиха: {d:.0f} м") one_deg_north = GeoPoint(moscow.lat_deg + 1.0, moscow.lon_deg) print(f"один градус широты: {haversine_distance_m(moscow, one_deg_north):.0f} м") print(f"та же точка: {haversine_distance_m(moscow, moscow):.2f} м")Python
Задача 15.2. Направление (bearing)
Файл практики: k15_task2_bearing.py.
Создайте модуль направления: функция bearing_deg() вычисляет начальный азимут через math.atan2() и приводит результат к компасному диапазону операцией остатка от деления на 360. Демо печатает направления на четыре стороны света и на Балашиху.
Ожидаемый результат. Демо печатает 0.00 град на север, 89.96 град на восток, 180.00 град на юг, 270.04 град на запад.
- Полный текст файла k15_task2_bearing.py
Скопируйте приведённое ниже содержимое в файл
code/M01_python/k15_task2_bearing.pyв проекте PyCharm (виртуальное окружение) и запустите его из каталогаcode/M01_python. Ниже приведён полный учебный текст файла; он совпадает с файлом в репозитории курса.# k15_task2_bearing.py # Цель: направление (bearing) из одной точки на другую — начальный азимут. # Канонический источник формул: # https://www.movable-type.co.uk/scripts/latlong.html # Запуск из каталога code/M01_python: # python k15_task2_bearing.py import math from k15_task1_haversine import GeoPoint def bearing_deg(p1: GeoPoint, p2: GeoPoint) -> float: """Начальное направление из p1 на p2: градусы от севера по часовой стрелке, 0..360. Тот же компасный договор, что и у курса (heading) в уроке K13, поэтому результат можно подавать в heading_to_velocity_ned(). """ lat1, lon1 = math.radians(p1.lat_deg), math.radians(p1.lon_deg) lat2, lon2 = math.radians(p2.lat_deg), math.radians(p2.lon_deg) d_lon = lon2 - lon1 y = math.sin(d_lon) * math.cos(lat2) x = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(d_lon) return math.degrees(math.atan2(y, x)) % 360.0 if __name__ == "__main__": home = GeoPoint(55.7558, 37.6173) north = GeoPoint(55.8558, 37.6173) east = GeoPoint(55.7558, 37.7173) south = GeoPoint(55.6558, 37.6173) west = GeoPoint(55.7558, 37.5173) balashikha = GeoPoint(55.8117, 37.9564) for title, target in (("север", north), ("восток", east), ("юг", south), ("запад", west)): print(f"направление на {title}: {bearing_deg(home, target):6.2f} град") print(f"направление Москва -> Балашиха: {bearing_deg(home, balashikha):6.2f} град")Python
Задача 15.3. Смещение точки
Файл практики: k15_task3_destination.py.
Создайте модуль смещения: функция destination_point() возвращает точку на заданной дистанции в заданном направлении канонической формулой конечной точки. Демо выполняет обратную проверку цепочкой: сместили точку — измерили расстояние и направление обратно.
Ожидаемый результат. Демо печатает обратную проверку: смещение на 100 м на север восстанавливается как 100.00 м и 0.00 град; смещение на 300 м на северо-восток — как 300.00 м и 45.00 град.
- Полный текст файла k15_task3_destination.py
Скопируйте приведённое ниже содержимое в файл
code/M01_python/k15_task3_destination.pyв проекте PyCharm (виртуальное окружение) и запустите его из каталогаcode/M01_python. Ниже приведён полный учебный текст файла; он совпадает с файлом в репозитории курса.# k15_task3_destination.py # Цель: смещение точки на заданное расстояние в заданном направлении — # формула конечной точки (destination point). # Канонический источник формул: # https://www.movable-type.co.uk/scripts/latlong.html # Запуск из каталога code/M01_python: # python k15_task3_destination.py import math from k15_task1_haversine import EARTH_RADIUS_M, GeoPoint, haversine_distance_m from k15_task2_bearing import bearing_deg def destination_point(origin: GeoPoint, bearing_target_deg: float, distance_m: float) -> GeoPoint: """Точка на расстоянии distance_m от origin в направлении bearing_target_deg (градусы).""" lat1 = math.radians(origin.lat_deg) lon1 = math.radians(origin.lon_deg) theta = math.radians(bearing_target_deg) delta = distance_m / EARTH_RADIUS_M # угловое расстояние в радианах lat2 = math.asin( math.sin(lat1) * math.cos(delta) + math.cos(lat1) * math.sin(delta) * math.cos(theta) ) lon2 = lon1 + math.atan2( math.sin(theta) * math.sin(delta) * math.cos(lat1), math.cos(delta) - math.sin(lat1) * math.sin(lat2), ) return GeoPoint(math.degrees(lat2), math.degrees(lon2)) if __name__ == "__main__": home = GeoPoint(55.7558, 37.6173) cases = ( ("север", 0.0, 100.0), ("восток", 90.0, 100.0), ("северо-восток по диагонали", 45.0, 300.0), ) for title, bearing, dist in cases: target = destination_point(home, bearing, dist) back_dist = haversine_distance_m(home, target) back_bearing = bearing_deg(home, target) print( f"{title}: lat={target.lat_deg:.7f} lon={target.lon_deg:.7f} | " f"обратная проверка: {back_dist:.2f} м, {back_bearing:.2f} град" )Python
Задача 15.4. Простейшая геозона
Файл практики: k15_task4_geofence.py.
Создайте модуль безопасности: функция geofence_status() проверяет расстояние от домашней точки (радиус) и относительную высоту (потолок) и возвращает один из трёх строковых статусов. Демо разбирает четыре сценария полёта.
Ожидаемый результат. Демо печатает статусы OK, OUT_OF_RADIUS для вылета на 150 м при радиусе 100 м и ABOVE_CEILING для высоты 60 м при потолке 50 м.
- Полный текст файла k15_task4_geofence.py
Скопируйте приведённое ниже содержимое в файл
code/M01_python/k15_task4_geofence.pyв проекте PyCharm (виртуальное окружение) и запустите его из каталогаcode/M01_python. Ниже приведён полный учебный текст файла; он совпадает с файлом в репозитории курса.# k15_task4_geofence.py # Цель: простейшая геозона (geofence) — проверка, что аппарат не покинул # разрешённую зону: расстояние от домашней точки и потолок высоты. # Запуск из каталога code/M01_python: # python k15_task4_geofence.py from k15_task1_haversine import GeoPoint, haversine_distance_m from k15_task3_destination import destination_point def geofence_status( position: GeoPoint, alt_m: float, home: GeoPoint, radius_m: float, max_alt_m: float, ) -> str: """Вернуть 'OK', 'OUT_OF_RADIUS' или 'ABOVE_CEILING'. alt_m — высота относительно домашней точки (упрощение: в MAVLink относительная высота и высота над уровнем моря — разные поля). Порядок проверок зафиксирован: сначала радиус, затем потолок. """ distance_m = haversine_distance_m(home, position) if distance_m > radius_m: return "OUT_OF_RADIUS" if alt_m > max_alt_m: return "ABOVE_CEILING" return "OK" if __name__ == "__main__": home = GeoPoint(55.7558, 37.6173) radius_m = 100.0 max_alt_m = 50.0 scenarios = ( ("висение на 20 м в 50 м от дома", destination_point(home, 0.0, 50.0), 20.0), ("вылетел за радиус на 150 м", destination_point(home, 0.0, 150.0), 20.0), ("поднялся на 60 м в пределах радиуса", destination_point(home, 0.0, 50.0), 60.0), ("у дома, высота 0", home, 0.0), ) for title, position, alt in scenarios: status = geofence_status(position, alt, home, radius_m, max_alt_m) dist = haversine_distance_m(home, position) print(f"{title}: dist {dist:6.1f} м, alt {alt:4.1f} м -> {status}")Python
Задача 15.5. Тесты геодезии
Файл практики: k15_task5_test_geo.py.
Создайте файл тестов: три опорных значения расстояния, три проверки направления, три проверки смещения точки и три сценария геозоны — всего двенадцать тестов с допусками pytest.approx (относительный rel для метров, абсолютный abs для градусов).
Ожидаемый результат. Команда python -m pytest k15_task5_test_geo.py -v показывает двенадцать строк PASSED и итог «12 passed».
- Полный текст файла k15_task5_test_geo.py
Скопируйте приведённое ниже содержимое в файл
code/M01_python/k15_task5_test_geo.pyв проекте PyCharm (виртуальное окружение) и запустите тесты командойpython -m pytest k15_task5_test_geo.py -vиз каталогаcode/M01_python. Ниже приведён полный учебный текст файла; он совпадает с файлом в репозитории курса.# k15_task5_test_geo.py # Тесты геодезического модуля K15: гаверсинус, bearing, смещение точки, геозона. # Запуск из каталога code/M01_python: # python -m pytest k15_task5_test_geo.py -v import math import pytest from k15_task1_haversine import EARTH_RADIUS_M, GeoPoint, haversine_distance_m from k15_task2_bearing import bearing_deg from k15_task3_destination import destination_point from k15_task4_geofence import geofence_status HOME = GeoPoint(55.7558, 37.6173) # --- гаверсинус ------------------------------------------------------------ def test_same_point_distance_is_zero(): assert haversine_distance_m(HOME, HOME) == pytest.approx(0.0, abs=1e-6) def test_one_degree_latitude(): north = GeoPoint(HOME.lat_deg + 1.0, HOME.lon_deg) expected = EARTH_RADIUS_M * math.radians(1.0) assert haversine_distance_m(HOME, north) == pytest.approx(expected, rel=1e-9) def test_distance_is_symmetric(): other = GeoPoint(55.8117, 37.9564) assert haversine_distance_m(HOME, other) == pytest.approx( haversine_distance_m(other, HOME) ) # --- bearing ---------------------------------------------------------------- def test_bearing_north_is_zero(): north = GeoPoint(HOME.lat_deg + 0.1, HOME.lon_deg) assert bearing_deg(HOME, north) == pytest.approx(0.0, abs=1e-9) def test_bearing_south_is_180(): south = GeoPoint(HOME.lat_deg - 0.1, HOME.lon_deg) assert bearing_deg(HOME, south) == pytest.approx(180.0, abs=1e-9) def test_bearing_east_near_90(): east = GeoPoint(HOME.lat_deg, HOME.lon_deg + 0.1) # Большой круг слегка «выгибается» к полюсу, поэтому курс на точку # той же широты чуть меньше 90 град; на малых расстояниях — доли градуса. assert bearing_deg(HOME, east) == pytest.approx(90.0, abs=0.5) # --- смещение точки ----------------------------------------------------------- def test_destination_round_trip_distance(): target = destination_point(HOME, 45.0, 300.0) assert haversine_distance_m(HOME, target) == pytest.approx(300.0, rel=1e-6) def test_destination_north_increases_latitude(): target = destination_point(HOME, 0.0, 100.0) assert target.lat_deg > HOME.lat_deg assert target.lon_deg == pytest.approx(HOME.lon_deg, abs=1e-9) def test_destination_bearing_consistency(): target = destination_point(HOME, 137.0, 500.0) assert bearing_deg(HOME, target) == pytest.approx(137.0, abs=0.1) # --- геозона ---------------------------------------------------------------- def test_geofence_ok(): inside = destination_point(HOME, 90.0, 50.0) assert geofence_status(inside, 20.0, HOME, 100.0, 50.0) == "OK" def test_geofence_out_of_radius(): outside = destination_point(HOME, 90.0, 150.0) assert geofence_status(outside, 20.0, HOME, 100.0, 50.0) == "OUT_OF_RADIUS" def test_geofence_above_ceiling(): inside = destination_point(HOME, 90.0, 50.0) assert geofence_status(inside, 60.0, HOME, 100.0, 50.0) == "ABOVE_CEILING"Python
Задача 15.6. Прогоны демо и тестов (обязательно выполнить)
Выполните команды по порядку из каталога code/M01_python в терминале с активным виртуальным окружением.
# Каталог: code/M01_python (venv активен, новых пакетов не требуется)
python k15_task1_haversine.py
python k15_task2_bearing.py
python k15_task3_destination.py
python k15_task4_geofence.py
python -m pytest k15_task5_test_geo.py -v
Ожидаемый результат. Ключевые строки вывода демо (сверьте значения):
task1: Москва -> Балашиха: 22095 м
один градус широты: 111195 м
та же точка: 0.00 м
task2: направление на север: 0.00 град
направление на восток: 89.96 град
направление на юг: 180.00 град
направление на запад: 270.04 град
направление Москва -> Балашиха: 73.52 град
task3: север: lat=55.7566993 lon=37.6173000 | обратная проверка: 100.00 м, 0.00 град
восток: lat=55.7558000 lon=37.6188982 | обратная проверка: 100.00 м, 90.00 град
северо-восток по диагонали: lat=55.7577077 lon=37.6206904 | обратная проверка: 300.00 м, 45.00 град
task4: висение на 20 м в 50 м от дома: dist 50.0 м, alt 20.0 м -> OK
вылетел за радиус на 150 м: dist 150.0 м, alt 20.0 м -> OUT_OF_RADIUS
поднялся на 60 м в пределах радиуса: dist 50.0 м, alt 60.0 м -> ABOVE_CEILING
tests: 12 passed
Значение 89.96 град для направления строго на восток объяснено в разделе 6: дуга большого круга между точками одной параллели слегка выгибается к полюсу.
Задача 15.7. Сохранение в Git
Добавьте в репозиторий пять файлов урока и сделайте коммит — последний в модуле M2.
# Из корня репозитория курса
git add code/M01_python/k15_task1_haversine.py code/M01_python/k15_task2_bearing.py code/M01_python/k15_task3_destination.py code/M01_python/k15_task4_geofence.py code/M01_python/k15_task5_test_geo.py
git commit -m "K15: geo haversine, bearing, destination point, geofence, 12 tests"
Ожидаемый результат. Команда git status показывает чистое рабочее дерево по файлам k15_*, в истории появился коммит урока K15.
12. Проверьте себя
- Что измеряют широта и долгота и в каких диапазонах они изменяются?
- Что такое WGS-84 и почему для наших задач Землю достаточно считать шаром среднего радиуса?
- Почему один градус долготы соответствует разным расстояниям на разных широтах и как это связано с отказом от теоремы Пифагора по градусам?
- Что такое большой круг и какую величину вычисляет формула гаверсинуса?
- В какой договорённости функция
bearing_deg()возвращает направление и в какую функцию урока K13 его можно подать напрямую? - Почему направление на точку той же широты равно 89.96°, а не ровно 90°?
- Какую задачу решает функция
destination_point()? Приведите два примера из планирования миссий. - Что такое домашняя позиция и как она связана с командой RTL и относительной высотой?
- Какие статусы возвращает наша геозона и какие условия за ними стоят?
- Почему сообщение
GLOBAL_POSITION_INTпередаёт координаты целыми числами, умноженными на 10⁷, и в каких единицах приходят высоты?
13. Чек-лист самопроверки
- Я создал все пять файлов практики в каталоге
code/M01_python. - Демо задачи 15.1 напечатало 22095 м для пары Москва — Балашиха и 111195 м для одного градуса широты.
- Я могу объяснить, почему направление строго на восток получилось 89.96°, а не 90.00°.
- Демо задачи 15.3 показало обратную проверку: дистанция и направление восстанавливаются после смещения.
- Демо задачи 15.4 показало все три статуса геозоны:
OK,OUT_OF_RADIUS,ABOVE_CEILING. - Команда
python -m pytest k15_task5_test_geo.py -vзавершается результатом 12 passed. - Я могу объяснить разницу высоты над уровнем моря и относительной высоты и назвать поля
GLOBAL_POSITION_INT. - Я сделал git commit с файлами урока K15.
K15 чек-лист закрыт. Это последний конспект модуля M2 «Математика дрона»: после него ментор зафиксирует закрытие модуля и выдаст K16 — первый урок модуля M3 «Протоколы и интерфейсы».14. Что дальше
- Предыдущий урок: K14 · Углы Эйлера, кватернионы и DCM
- Текущий урок: K15 · Геокоординаты: lat/lon/alt, расстояние haversine, направление bearing
- Следующий урок: K16 · Слои связи «для дронов» — начало модуля M3 «Протоколы и интерфейсы»
- Модуль M3 пройдёт путь от физики байтов на проводе до UDP-сокетов и ручного разбора бинарных кадров; после лабораторной работы K22 сдаётся гейт G2.