Как считается план: галсы, покрытие, ветер, энергия, критерии¶
Этот раздел описывает предметную логику планировщика geoscan — что происходит от полигона съёмки
до JSON-плана с вылетами и фазами, и как тот же план проверяет валидатор. Теория и вывод формул —
в ../../30-domain/ (точка входа: 01-survey-geometry.md,
03-coverage-path-planning.md,
05-energy-and-wind.md). Реализация — в
src/backend/planner/, проверки — в src/backend/validator/. Каталог simulate/** здесь не
рассматривается.
| Кому читать | Что вынести |
|---|---|
| Жюри / заказчик | Конвейер «вход → галсы → борта → вылеты», два критерия оптимизации, что значит «допущен» |
| Разработчик | Точки входа в коде, ограничения MVP, ссылки на строки |
| Оператор | Команды planner run и validator check, типичные предупреждения (150 м, частичное покрытие) |
Смежные разделы: бизнес-контекст, сценарии, архитектура, API (черновик), эксплуатация.
Команды в этом разделе (uv run python …, planner run, validator check) выполняйте из каталога
src/backend — иначе Python не найдёт пакеты planner и validator. В каждом блоке ниже есть явный
cd src/backend.
Конвейер расчёта¶
Функция plan() в src/backend/planner/planner/pipeline.py:384-846 — единая точка сборки. Этапы
соответствуют событиям прогресса ProgressEvent (PARSE → COVERAGE → ROUTING → SOLVING → PACK →
EXPORT).
flowchart TB
subgraph input [Вход сценария]
A[Полигон задачи + тип съёмки + GSD]
B[Парк БВС и ЛЗП]
C[ВП: allowed + NFZ + буфер]
D[Ветер и objective]
end
subgraph per_task [На каждую задачу]
E[Подбор бортов: payload, wind_max]
F[Высота по GSD + лимиты борта]
G["clip_task_area: allowed ∩ area − NFZ.buffer"]
H[Угол галсов: task.angle_deg или перебор 0…179°]
I[generate_transects: шаг By, змейка]
end
subgraph assign [Распределение]
J[assign_transects: MIP или balance]
K[order_transects: TSP по борту]
L["_pack_mission: вылеты, NFZ-обход, резка галсов"]
end
subgraph out [Выход]
M[Plan: missions, geometry, metrics]
N[enrich_metrics + статус solver]
end
input --> per_task
G --> H --> I
I --> J --> K --> L
L --> M --> N
Вывод из диаграммы: геометрия покрытия (галсы) считается до назначения бортам; упаковка во вылеты учитывает энергобюджет и обход NFZ на перелётах, но не пересчитывает сетку галсов.
От области съёмки до галсов¶
1. Пригодная площадь¶
Для каждой задачи берётся эталонный борт из совместимых (pipeline.py:445-472), по его оптике
считается высота, затем:
flyable = task_polygon ∩ airspace.allowed − ⋃ NFZ.buffer(safety_buffer_m)
Реализация: clip_task_area() в src/backend/planner/planner/coverage/airspace_clip.py:61-92.
Зоны NFZ учитываются только если текущая высота съёмки попадает в их слой
_zone_applies() (airspace_clip.py:23-30). Дыры в полигоне (кольца GeoJSON) поддерживаются в
generate_transects() через Shapely (transects.py:54-132).
Если после клипа не осталось частей — warning AIRSPACE_EMPTY, задача пропускается
(pipeline.py:488-497).
2. Шаг галсов и перекрытия¶
Поперечный шаг (межмаршрутное расстояние):
By = W_across · (1 − q_side)
где W_across — поперечный размер отпечатка кадра. В коде: swath_spacing_m() —
optics.py:86-97; для geophysical можно задать фиксированный line_spacing_m.
Дефолты перекрытий, если в задаче не заданы: продольное 70%, поперечное 60%
(src/backend/shared-py/geoscan_contracts/scenario_helpers.py:17-22).
Продольный интервал съёмки (базис вдоль галса):
Δt = W_along · (1 − q_fwd) / V_g
trigger_interval_s() — optics.py:100-107; V_g берётся с учётом ветра (см. ниже).
3. Направление галсов¶
- Если в задаче задан
angle_deg— используется он (pipeline.py:505-507). - Иначе перебор углов 0…179° с шагом 1°: для каждого угла строятся галсы, суммируется время
проходов с ветром плюс
(N_turn − 1) · t_turn(pipeline.py:90-119,costs.py:28-38).
Разворот для самолёта: радиус из крейсерской скорости и крена 30°, для мультиротора — фиксированные
5–8 с в зависимости от turn_mode (lzp / flyby).
4. Генерация линий¶
generate_transects() (transects.py:55-132):
- полигон поворачивают так, чтобы галсы стали горизонтальными скан-линиями;
- первая линия на
miny + By/2, далее шагBy; - пересечение скана с полигоном даёт отрезки; короткие отрезки (<
0.25·By) отбрасываются; - направление чередуется (змейка) флагом
flip.
На карте в UI оператор видит линии галсов и шаг из блока geometry плана (TaskGeometry.transects).
5. Бесполётные зоны на перелётах¶
Клип убирает NFZ из площади съёмки, но перелёт к началу галса и домой строится отдельно:
route_avoiding_nfz() — граф обхода по сетке (nfz_route.py). Если маршрут не найден — warning
NFZ_DETOUR_NOT_FOUND, траектория всё равно записывается (pipeline.py:212-222). Такой план
валидатор отклонит по G1-01/G1-04, если сегмент реально заходит в зону.
Высота и GSD¶
Формулы в коде¶
Каталог несёт предвычисленный коэффициент gsd_coefficient (см. docs/task5/10-hardware/payloads.yaml):
GSD [м/пикс] = H · gsd_coefficient
GSD [см/пикс] = 100 · GSD [м/пикс]
gsd_m / gsd_cm — optics.py:39-44. Обратная задача:
H = (GSD_cm / 100) / gsd_coefficient
altitude_for_gsd_m() — optics.py:47-75 с проверками:
| Проверка | Код |
|---|---|
AGL ≥ altitude_agl_min_m |
ALTITUDE_BELOW_MIN |
AGL ≤ altitude_agl_max_m |
ALTITUDE_ABOVE_MAX |
elevation_ЛЗП + H ≤ altitude_asl_max_m |
ALTITUDE_ASL_ABOVE_MAX |
Отпечаток кадра: footprint_m() — scale = H / f_mm, ширина/высота матрицы в мм (optics.py:78-83).
Пример с числами (сверка с кодом)¶
Нагрузка geoscan_pf1b, целевой GSD = 3 см, борт geoscan_gemini (лимиты из каталога).
cd src/backend
uv run python -c "
from geoscan_contracts.catalog import load_catalog
from planner.geometry.optics import altitude_for_gsd_m, gsd_cm, footprint_m, swath_spacing_m, optics_from_payload
from geoscan_contracts.enums import SurveyType
cat = load_catalog()
o = optics_from_payload(cat.payload('geoscan_pf1b'))
h = altitude_for_gsd_m(o, 3.0, uav=cat.uav_model('geoscan_gemini'))
print('H=', round(h, 2), 'gsd_cm=', round(gsd_cm(o, h), 4))
fp = footprint_m(o, h)
print('across', round(fp.across_m, 2), 'along', round(fp.along_m, 2))
print('By', round(swath_spacing_m(o, h, SurveyType.RGB, 0.60), 2))
"
Фактический вывод в этой ветке (2026-09-17):
H= 153.19 gsd_cm= 3.0
across 180.0 along 119.49
By 72.0
Ручная проверка: H = 0.03 / 1.9583e-4 ≈ 153.19 м; across = 153.19/20·23.5 ≈ 180 м;
By = 180·0.4 = 72 м — совпадает с test_geometry_optics.py:31-38.
Эталонный сценарий scenarios/s01-simple.json ожидает те же порядки величин в блоке expected
(высота 153.2 м, шаг 72 м). После расчёта план помечает above_ivp_threshold при H > 150 м
(pipeline.py:770); валидатор выдаёт G1-05 warn, допуск не снимает.
Ветер¶
Путевая скорость¶
Метеорологическое направление «откуда дует» (wind_direction_deg). Реализация
ground_speed_ms() — wind.py:8-22:
W∥ = W · cos(ψ), W⊥ = W · sin(ψ) (ψ — угол между курсом и направлением ветра «куда»)
V_g = sqrt(V_a² − W⊥²) + W∥
При V_a² ≤ W⊥² — Infeasible("CROSSWIND_EXCEEDS_AIRSPEED").
Время галса¶
transect_time_s() считает половину длины по курсу track_deg и половину по обратному курсу
(wind.py:25-34) — учёт разного ветра на «туда» и «обратно» вдоль одного отрезка.
Проверка (тот же модуль):
cd src/backend
uv run python -c "
from planner.geometry.wind import ground_speed_ms, transect_time_s
va, w = 15.0, 8.0
for track in (0, 90):
vg = ground_speed_ms(va, w, 270.0, float(track))
t = transect_time_s(1000.0, va, w, 270.0, float(track))
print(track, 'Vg', round(vg,3), 'T1km', round(t,1))
"
Вывод:
0 Vg 12.689 T1km 78.8
90 Vg 23.0 T1km 93.2
При ветре с запада (wind_from=270°) галс с азимутом 90° (восток) получает большую путевую скорость,
но парный проход «туда–обратно» на 1 км длиннее, чем при азимуте 0° — это и минимизирует
перебор угла в _optimal_angle_deg().
Где ветер участвует в плане¶
| Этап | Ветер учтён? |
|---|---|
Отбор борта wind_max_ms |
да (pipeline.py:433) |
Время галса, выбор угла, trigger_interval_s |
да |
Перелёт TRANSIT в _pack_mission |
нет — distance / airspeed_cruise (pipeline.py:225-233) |
| Посадка против ветра | не реализовано в планировщике |
Энергия и вылеты¶
Модель MVP¶
Используется бюджет по времени, не ватт-часы:
endurance_s = endurance_max_min · 60
budget_s = endurance_s · (1 − energy_reserve)
energy_reserve из scenario.objective (типично 0.2) — pipeline.py:177-178.
Фазы вылета (_pack_mission, pipeline.py:157-381):
| Фаза | Длительность |
|---|---|
| TAKEOFF | 30 с |
| TRANSIT / TURN | путь / крейсер (без ветра) |
| SURVEY | transect_time_s(...) |
| LANDING | 60 с |
Между вылетами одного борта добавляется turnaround_time_min ЛЗП (pipeline.py:368-369).
energy_used_frac вылета = duration_s / endurance_s (ограничено 1.0) — pipeline.py:361.
Если галс не влезает в остаток бюджета, он режется split_transect() или попадает в unassigned
с причиной ENDURANCE_EXCEEDED (pipeline.py:236-272).
Распределение по бортам и критерии¶
Два рабочих критерия из ТЗ¶
В контракте: Criterion.MAKESPAN, Criterion.TOTAL_FLIGHT_TIME (enums.py:32-37). Также есть
PARETO и NAIVE (бейзлайн для таблицы валидатора); назначение галсов для makespan и
total_flight_time идёт через balance_assign_transects() (assign.py:42-43), иначе через MIP
mip_assign_transects() (assign.py:45-51).
| Критерий | Целевая функция назначения | Файл |
|---|---|---|
makespan |
минимизировать максимальную загрузку борта (время) | balance_assign.py:47, mip_assign.py:63-66 |
total_flight_time |
минимизировать сумму загрузок | balance_assign.py:45-46, mip_assign.py:60-61 |
После назначения для каждого борта порядок галсов упорядочивается TSP (tsp_order.py).
Смешанный парк с разным шагом By (разные камеры): включается режим полос по площади
equal_area_weights + split_into_bands (pipeline.py:540-615, флаг use_area_bands).
CLI переопределяет критерий:
cd src/backend
uv run python -m planner run --scenario scenarios/s01-simple.json --objective makespan --out /tmp/s01-plan.json
Валидация плана¶
Валидатор не строит план заново (src/backend/validator/validator/validate.py:1-4); он
пересчитывает метрики и прогоняет 24 проверки по реестру
src/backend/validator/validator/checks/__init__.py:28-35. Подробная таблица —
validator-checks.md.
sequenceDiagram
participant Op as Оператор / CI
participant P as planner.plan()
participant V as validator.validate()
participant C as checks.run_all()
participant A as admit()
Op->>P: Scenario + PlanOptions
P-->>Op: Plan JSON
Op->>V: scenario + plan
V->>V: build_context()
V->>C: 24 проверки (G1…G6)
C-->>V: CheckResult[]
V->>A: aggregate_violations
A-->>Op: admitted, violations, failed_check_ids
mindmap
root((Валидатор 24 проверки))
G1 ВП и зоны
NFZ G1-01
Allowed G1-02
Слой высот G1-03
Буфер G1-04
150м ИВП G1-05
G2 Высота
Потолок G2-01
Минимум G2-02
Ниже старта G2-03
Рельеф G2-04 нет данных
G3 Энергия
Бюджет G3-01
Фазы G3-02
Оборот G3-03
energy_used G3-04
G4 Погода
wind_max G4-01
Поперечный G4-02
V_g G4-03
G5 Назначение
Payload G5-01
Уникальность G5-02
Unassigned G5-03 G5-04
G6 Покрытие и метрики
coverage G6-01
Честное время G6-02
Развороты G6-03
Makespan G6-04
Что значит «допущен»: нет жёстких нарушений (сумма ViolationCounts = 0) и нет статуса fail.
Предупреждения (150 м, G2-04) план к полёту формально не блокируют, но оператор должен их прочитать.
Пример прогона для s01-simple (ветер 0, один вылет, 15 галсов, покрытие ≈ 0.9999):
cd src/backend
uv run python -m planner run --scenario scenarios/s01-simple.json --objective makespan --out /tmp/s01-plan.json
uv run python -m validator check --scenario scenarios/s01-simple.json --plan /tmp/s01-plan.json
Фрагмент отчёта (на 2026-09-17, ветка docs2/gs-domain): проверки G1–G6 в статусе ok, кроме
предупреждений G1-05 (153 м > 150 м) и G2-04 (нет DEM). На сценариях с NFZ или смешанным парком
набор warn/fail может отличаться от старых отчётов приёмки — ориентируйтесь на актуальный вывод
validator check.
Покрытие в планировщике: union полос вдоль SURVEY-фаз / площадь задачи минус NFZ
(metrics.py:66+); валидатор считает независимо (validator/metrics.py:38+) и сверяет в G6-01.
Доменные сущности (контракт)¶
classDiagram
class Scenario {
+tasks: SurveyTask[]
+fleet: UavUnit[]
+launch_sites: LaunchSite[]
+airspace: Airspace
+weather: Weather
+objective: Objective
}
class SurveyTask {
+survey_type
+quality.gsd_cm
+area: Polygon
+angle_deg?
}
class Plan {
+missions: Mission[]
+geometry: TaskGeometry[]
+metrics: PlanMetrics
+unassigned: Unassigned[]
}
class Mission {
+uav_id
+sorties: Sortie[]
}
class Sortie {
+duration_s
+energy_used_frac
+phases: Phase[]
}
class Phase {
+PhaseKind
+path
+transect_id?
}
class TaskGeometry {
+altitude_m
+swath_spacing_m
+transects: Transect[]
}
Scenario --> SurveyTask
Scenario --> Plan : planner.plan()
Plan --> Mission
Mission --> Sortie
Sortie --> Phase
Plan --> TaskGeometry
Типы определены в src/backend/shared-py/geoscan_contracts/ (scenario.py, plan.py).
Ограничения и честные границы MVP¶
| Тема | Статус |
|---|---|
| Модель энергии в Вт·ч | не реализована, только время + energy_used_frac |
| Ветер на перелётах | не учитывается |
| Рельеф / запас над землёй | G2-04 всегда предупреждение |
| Высотные NFZ «съёмка под зоной» | NFZ вычитается из площади целиком при клипе |
Критерий pareto в CLI |
объявлен, отдельного фронта Парето в plan() нет |
| Декомпозиция на выпуклые части | в коде — один полигон с дырами через Shapely, без отдельного CPP-модуля |
При расхождении этого текста с кодом верным источником считается код на ветке docs2/gs-domain.
Быстрые ссылки на код¶
| Вопрос | Файл |
|---|---|
| Главный конвейер | planner/pipeline.py |
| GSD, шаг, триггер | planner/geometry/optics.py |
| Ветер | planner/geometry/wind.py |
| Галсы | planner/coverage/transects.py |
| ВП / NFZ площадь | planner/coverage/airspace_clip.py |
| Обход NFZ в пути | planner/coverage/nfz_route.py |
| Назначение / критерий | planner/routing/assign.py, balance_assign.py, mip_assign.py |
| Упаковка вылетов | planner/pipeline.py (_pack_mission) |
| 24 проверки | validator/checks/*.py |
| Допуск | validator/admission.py |