Перейти к содержанию

Как считается план: галсы, покрытие, ветер, энергия, критерии

Этот раздел описывает предметную логику планировщика 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_cmoptics.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