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

Архитектура симулятора geoscan-sim

О чём этот раздел и кому читать. Здесь описано, как устроен headless-симулятор в каталоге simulate/: из каких модулей он собран, с каким шагом модельного времени считает полёт, что попадает в журнал телеметрии и как помечается достоверность выходных чисел. Жюри и заказчик — раздел «Зачем это в хакатоне» и диаграмма контекста. Разработчик — слои кода, последовательность шага симуляции и ссылки на файлы. Оператор — команда прогона, что лежит в каталоге результата и как проверить детерминизм. Соседние продукты: планировщик geoscan, бизнес-смысл симулятора 01-business, API и CLI 04-api (черновик), эксплуатация 05-operate. Детали журнала и fidelity_class02-journal-and-fidelity.md.


Зачем это в хакатоне

Симулятор принимает тот же план полёта (JSON), что выдаёт сервис планирования, и прогоняет его в синтетическом мире с ТТХ бортов из fleet.yaml. На выходе — факт: длительность вылета, энергия, покрытие съёмки (геометрия кадров), коды preflight, хеш телеметрии. Это нужно, чтобы сравнивать «план vs факт» без реального полёта (mission/fact_report.py:24-30). Планировщик оптимизирует маршрут; симулятор не перепланирует галсы — он следует геометрии фаз из плана (engine/phases.py:42-73) с поправкой времени движущих фаз по длине пути и скорости (phases.py:76+).

Ограничения поставки (нет реального GLO-30, нет sim batch, сухие кадры без TIFF) перечислены честно в LIMITATIONS.md — архитектура ниже описывает то, что уже в коде.


Контекст системы (C4)

Внешние акторы и граница симулятора. HTTP-сервер и CLI вызывают один и тот же пайплайн run_plan_headless (mission/runner.py:187).

C4Context
  title Контекст симулятора geoscan-sim
  Person(operator, "Оператор / разработчик", "Запускает прогон, смотрит журнал и UI")
  Person(jury, "Жюри / заказчик", "Сверяет план и факт по отчётам")
  System_Ext(geoscan, "geoscan (планировщик)", "JSON-план, сценарий, метрики плана")
  System(sim, "geoscan-sim", "Headless-модель мира и полёта, журнал, fact_report")
  System_Ext(fixtures, "Фикстуры worlds/plans", "YAML/JSON миров и демо-планы")
  Rel(operator, sim, "sim run / POST /runs", "CLI, REST")
  Rel(jury, sim, "Читает fact_report, reference validate")
  Rel(geoscan, sim, "План JSON", "Файл или API")
  Rel(sim, fixtures, "WorldRuntime.from_name", "Синтетический рельеф и зоны")

Вывод: симулятор — отдельный продукт в simulate/; единственная жёсткая связь с geoscan — контракт плана и общие ТТХ (hardware/fleet.py). Веб-просмотрщик (simulate/web/) читает уже записанные telemetry.jsonl и не участвует в интеграции шага (web/src/playback/TelemetryPlayer.ts:3 — тот же dt_s = 0.1).

Контейнеры внутри geoscan-sim

flowchart TB
  subgraph cli_api["Вход"]
    CLI["cli: sim run"]
    API["api: RunService + workers"]
  end
  subgraph core["Ядро прогона"]
    MIS["mission: import, preflight, runner"]
    ENG["engine: HeadlessIntegrator"]
    WLD["world: WorldRuntime"]
    ENV["env: ветер, LOS, связь"]
    UAV["uav: энергия, path_follow, kinematics"]
    CAM["camera: dry frames, GSD"]
  end
  subgraph out["Выход"]
    JRN["journal: manifest + jsonl"]
    FACT["mission/fact_report"]
    VAL["validator: reference, mutants"]
  end
  CLI --> MIS
  API --> MIS
  MIS --> WLD
  MIS --> ENG
  ENG --> ENV
  ENG --> UAV
  ENG --> WLD
  MIS --> JRN
  MIS --> FACT
  MIS --> CAM
  VAL -.-> FACT

Модули и ответственность

Модуль Пакет Роль
Мир geoscan_sim.world Загрузка синтетического мира, высота рельефа, препятствия, зоны, world_manifest (world/runtime.py:22-83)
Борт geoscan_sim.uav Ветровой треугольник, следование полилинии, двухточечная модель энергии (uav/kinematics.py, uav/energy.py, uav/path_follow.py)
Движок geoscan_sim.engine Фиксированный шаг dt_s, цикл фаз, телеметрия и события (engine/integrator.py:1, 142-509)
Среда geoscan_sim.env Поле ветра, профиль LOS по рельефу, модель радиолинии и failsafe (env/hooks.py:59-75, env/link.py:19-55)
Миссия geoscan_sim.mission Импорт плана, preflight-детекторы, оркестрация прогона, fact_report (mission/runner.py, mission/preflight.py)
Камера geoscan_sim.camera Сухие кадры: футпринт, GSD, frames.jsonl без растра по умолчанию (camera/frames.py:1-2, 35-37)
Журнал geoscan_sim.journal run_manifest.json, telemetry.jsonl, events.jsonl, атомарная запись (journal/writer.py)
Достоверность geoscan_sim.fidelity Обёртки quantity, штамп записей, аудит покрытия (fidelity/quantity.py, fidelity/audit.py)
Валидатор geoscan_sim.validator Таблица V-1…V-14, мутанты, независимые оракулы (отдельно от шага симуляции)

classDiagram: связи ключевых типов

classDiagram
  class WorldRuntime {
    +terrain_z_m(e,n)
    +clearance_m(...)
    +world_snapshot_id
  }
  class HeadlessIntegrator {
    +run(IntegratorConfig, world)
  }
  class IntegratorConfig {
    dt_s = 0.1
    phases: PhaseSegment[]
    wind_speed_ms
  }
  class PhaseSegment {
    phase
    duration_s
    path: (e,n)[]
    speed_ms
  }
  class TelemetrySample {
    t_s
    east_m north_m
    phase link_up
    energy_left_wh
  }
  class RunJournalWriter {
    +write(RunResult, ctx)
  }
  class EnvRunContext {
    wind_field
    los_profiler
    link_events
  }
  class LinkModel {
    +step(dt_s, distance_m)
  }
  class WindField {
    +vector_enu_ms(...)
  }
  HeadlessIntegrator --> IntegratorConfig
  HeadlessIntegrator --> TelemetrySample
  HeadlessIntegrator --> WorldRuntime : terrain sample
  HeadlessIntegrator --> LinkModel
  HeadlessIntegrator ..> EnvRunContext : get_active_context
  EnvRunContext --> WindField
  RunJournalWriter --> TelemetrySample : JSONL rows
  mission.runner --> HeadlessIntegrator
  mission.runner --> WorldRuntime
  mission.runner --> RunJournalWriter

Шаг модельного времени

Параметр Значение в коде Где зафиксировано
Шаг интеграции 0,1 с IntegratorConfig.dt_s: float = 0.1 (engine/integrator.py:77)
Частота журнала 10 Гц telemetry_hz = 1.0 / cfg.dt_s (journal/writer.py:304-305)
Режим часов headless Событие run_start / манифест clock_mode (runner.py:87, writer.py:350)
Округление времени в выборке 6 знаков t_s=round(t, 6) (integrator.py:236, 443)

Цикл фазового прогона: на каждом шаге t_global += config.dt_s (integrator.py:463), пока не исчерпана суммарная длительность sortie (integrator.py:288-291, 311). Устаревший режим «прямой участок» без фаз — _run_legacy_straight (integrator.py:148-275); плановый прогон всегда идёт через phases (runner.py:252-262).

Просмотрщик UI воспроизводит записи с тем же шагом по умолчанию (web/src/playback/TelemetryPlayer.ts:3-15).


Один шаг симуляции (sequenceDiagram)

Упрощённая цепочка для фазы с полилинией (не return). Реализация — цикл в _run_phases (integrator.py:311-486).

sequenceDiagram
  participant I as HeadlessIntegrator
  participant P as path_follow
  participant W as WindField
  participant K as wind_triangle
  participant L as LinkModel
  participant LOS as LosProfiler
  participant T as WorldRuntime
  participant E as TwoAnchorEnergyModel

  I->>P: sample_polyline(path, seg_distance)
  P-->>I: east, north, track_deg
  I->>W: vector_enu_ms(e,n,h,t)
  W-->>I: u,v wind
  I->>K: v_air, wind, track
  K-->>I: v_ground, heading
  I->>LOS: evaluate_los_blocked (via env ctx)
  LOS-->>I: terrain_blocks
  I->>L: step(dt_s, dist_home, terrain_blocks)
  L-->>I: link_up, failsafe_triggered
  I->>E: power_w(v_air, T)
  E-->>I: energy_wh -= P*dt/3600
  I->>T: terrain_z_m, clearance_m
  T-->>I: alt_agl, clearance
  I->>I: append TelemetrySample

Вывод: за один шаг обновляются координаты (по пути или на возврате домой), ветер влияет на путевую скорость, связь и LOS могут включить failsafe и фазу return (integrator.py:335-345, 418-427), рельеф задаёт clearance_m только при переданном world (integrator.py:512-535).


Состояния борта в прогоне (stateDiagram)

Логические режимы на одном sortie: фазы из плана плюс прерывания по энергии и связи.

stateDiagram-v2
  [*] --> sortie_start: sortie_start event
  sortie_start --> phase_exec: mode по PhaseSegment
  phase_exec --> takeoff: phase=takeoff
  phase_exec --> survey: phase=survey
  phase_exec --> transit: phase=transit
  phase_exec --> other: landing/return из плана
  survey --> phase_exec: transect_end, следующий сегмент
  takeoff --> phase_exec: duration сегмента
  phase_exec --> return_home: ENERGY_RESERVE_BREACH\nили LINK_LOST_AUTONOMOUS_RETURN
  return_home --> touchdown: dist_home <= 1 m
  touchdown --> sortie_end
  phase_exec --> sortie_end: все сегменты без breach
  sortie_end --> [*]

События violation / contingency пишутся в sim_events (integrator.py:337-345, 415-427). Коды совпадают с payload в events.jsonl при events_mode="sim" (runner.py:292-293, journal/writer.py:161-182). Полное дерево из 12 contingency РЭ не реализовано — см. LIMITATIONS.md.


Пайплайн прогона плана

flowchart LR
  A[load_and_validate_plan] --> B[analyze_plan preflight]
  B --> C[WorldRuntime.from_name]
  C --> D[begin_env_run]
  D --> E[build_phase_segments]
  E --> F[HeadlessIntegrator.run]
  F --> G[RunJournalWriter.write]
  G --> H[build_fact_report]
  H --> I[materialize_dry_frames_headless]
  D --> F
  F --> J[end_env_run link_events]
  J --> G

Точка входа: run_plan_headless (mission/runner.py:187-344). CLI оборачивает её в sim run (cli/__init__.py:66-128). Несколько sortie в плане выполняются последовательно с накоплением t_start_s (runner.py:234-265) — параллельного мультиборта в одном модельном времени нет.


Детерминизм и воспроизводимость

Утверждение Подтверждение в коде
В цикле интегратора нет вызовов random Поиск по engine/integrator.py — только детерминированная арифметика; seed хранится в конфиге и манифесте, но не потребляется в цикле (integrator.py:76, journal/writer.py:347)
Повторный прогон даёт тот же digest телеметрии Хеш считает RunResult.compute_hash — SHA256 от JSON списка сэмплов (integrator.py:136-139); тест 10× вызывает обёртку run_deterministic_telemetry_hash (tests/test_determinism.py:7-9), а не RunResult напрямую
В fact_report явно stochastic: false mission/fact_report.py:401-407
reproducible_sha256 манифеста стабилен при тех же входах tests/test_journal_schema.py:41-52
seed мира в фикстурах фиксирован Генерация синтетики с явным seed (world/synthetic.py:4, 152+)

Ограничение: детерминизм проверяется на одной платформе (Linux x86-64 в CI); кросс-ОС бит-идентичность не заявлена (LIMITATIONS.md).

Поле telemetry_sha256 в манифесте считается по строкам JSONL на диске (journal/writer.py:42-47, 322), после прогона runner дополнительно синхронизирует манифест с файлом (runner.py:135-151).


Журнал телеметрии (erDiagram)

Связь сущностей артефактов одного прогона (логическая модель, не SQL).

erDiagram
  RUN_MANIFEST ||--o{ TELEMETRY_LINE : contains_hash
  RUN_MANIFEST ||--o{ EVENT_LINE : references_run
  RUN_MANIFEST }o--|| REPRODUCIBLE_BLOCK : embeds
  RUN_MANIFEST }o--o| PLAN : plan_sha256
  WORLD_MANIFEST ||--|| RUN_MANIFEST : world_snapshot_id
  FACT_REPORT ||--|| RUN_MANIFEST : telemetry_sha256
  FACT_REPORT ||--o{ PLAN_METRIC : plan_vs_fact
  TELEMETRY_LINE }o--|| UAV : uav_id
  FRAMES_LINE }o--|| RUN_MANIFEST : same run_dir

  RUN_MANIFEST {
    string run_id PK
    float dt_s
    float telemetry_hz
    string telemetry_sha256
    string reproducible_sha256
    string clock_mode
  }
  REPRODUCIBLE_BLOCK {
    int seed
    string world_snapshot_id
    string plan_sha256
    json phase_durations_s
  }
  TELEMETRY_LINE {
    float t_sim_s PK
    string uav_id
    float east_m
    float north_m
    string mode
    string fidelity_class
  }
  EVENT_LINE {
    float t_sim_s
    int seq
    string type
    json payload
  }
  FACT_REPORT {
    string telemetry_sha256
    bool determinism_stochastic
  }

Подробная таблица полей — 02-journal-and-fidelity.md.


Как считается достоверность (fidelity_class)

  1. Каждая числовая величина в отчётах должна иметь предка с fidelity_class, depends_on, validation_level — иначе падает валидация (fidelity/validate.py:38-49).
  2. Телеметрия — все числа в строке наследуют класс derived и набор INTEGRATOR_DEPENDS (journal/writer.py:153-157, fidelity/quantity.py:26-31).
  3. Манифест прогона — корень помечается annotate_artifact_root (journal/writer.py:382-387).
  4. План vs факт — метрики времени/энергии derived, плановое покрытие без пересчёта в симе может быть assumed (mission/fact_report.py — см. обёртки quantity в build_plan_vs_fact).
  5. Аудит каталогаaudit_run_directory считает долю «голых» чисел (fidelity/audit.py:51-57); для s01 ожидается 0 % (tests/test_fidelity_class.py:31-37).

Классы: measured | derived | assumed (fidelity/quantity.py:7). Уровень V3 (независимая перепроверка всех derived) в коде объявлен как будущий этап (fidelity/quantity.py:18); независимые оракулы живут в validator/independent/ и используются таблицей reference, а не в каждом шаге интегратора.

Инструмент для оператора после прогона:

cd simulate
uv run python tools/audit_fidelity_coverage.py /path/to/run-dir

Для оператора: проверяемый прогон

Команда (из корня репозитория, каталог simulate/):

cd simulate
rm -rf /tmp/sim-arch-doc-out
uv run sim run fixtures/plans/s01.json --seed 42 --output /tmp/sim-arch-doc-out --world m0-synthetic-flat

Фактический вывод (сессия документирования, 2026-09-17):

{
  "fact_report": "/tmp/sim-arch-doc-out/fact_report.json",
  "frames": "/tmp/sim-arch-doc-out/frames.jsonl",
  "frames_raster_count": 0,
  "frames_written": 380,
  "manifest": "/tmp/sim-arch-doc-out/run_manifest.json",
  "preflight_codes": [],
  "t_out_of_link_max_s": 0.0,
  "telemetry": "/tmp/sim-arch-doc-out/telemetry.jsonl",
  "telemetry_sha256": "e11273715f65affb7987923aeb444d85ded8c8919264af05a2cf446cf4b62371",
  "world_snapshot_id": "24e205faab997a835132abc87aeaf74d8e2760e528ec35d2c013c277af0c11a3"
}

Проверка манифеста:

python3 -c "import json; m=json.load(open('/tmp/sim-arch-doc-out/run_manifest.json')); print(m['dt_s'], m['telemetry_hz'], m['sim_time_s'], m['clock_mode'])"

Вывод: 0.1 10.0 1680.0 headless16800 строк в telemetry.jsonl (1680 с / 0,1 с).

Повтор sim run в тот же --output без удаления каталога завершается с exit code 3 (SimExit.INTERNAL, cli/__init__.py:152). В stderr JSON с "code": "INTERNAL_ERROR" и "message": "run_id already exists: …" — CLI оборачивает любое необработанное исключение, в том числе RunIdCollisionError, в INTERNAL_ERROR (cli/__init__.py:131-152), хотя у самого исключения RunIdCollisionError.code == "RUN_ID_COLLISION" (journal/writer.py:32-35). Через HTTP API повтор с тем же run_id даёт 409 и код RUN_ID_COLLISION (api/routes/runs.py:72, tests/test_api_errors.py:51). Известный дефект маппинга CLI — simulate/docs/94-waves/2-merge-log.md (../../../docs/94-waves/2-merge-log.md) (A9/cli).

Тест детерминизма движка:

cd simulate && uv run pytest -q tests/test_determinism.py --tb=no

Вывод: 3 passed in 1.15s.

На экране Run в веб-UI оператор видит текущую запись телеметрии и карту по getTelemetry(runId) (web/src/screens/RunScreen.tsx:48-58, 106); воспроизведение не меняет модель — только индекс в TelemetryPlayer.


Что реализовано частично или не реализовано

Тема Статус Где смотреть
Реальный рельеф GLO-30 Не в поставке LIMITATIONS.md, world/mirror blocked
Стохастика / шум датчиков Нет fact_report.determinism.stochastic: false
Растровые кадры TIFF По умолчанию 0 растров frames_raster_count: 0 в выводе CLI
sim batch, checkpoint Нет команды LIMITATIONS.md, cli/__init__.py
Потоковая телеметрия с бюджетом ≤20 МБ / 180 мин Не выполнен критерий реестра LIMITATIONS.md, tests/test_journal_schema.py
Баланс энергии в валидаторе Заглушка STUB tests/test_determinism.py:20-22, validator/invariants.py
Seed CLI влияет на траекторию Нет в интеграторе — только в манифесте integrator.pyseed не читается в цикле

Полный перечень — LIMITATIONS.md.


Связанные разделы документации

Раздел Путь
Сценарии и акторы симулятора 02-usecases
HTTP, CLI, схемы 04-api — на доработке
Запуск стенда, чтение результатов 05-operate
Требования к миру (исторические) docs/10-world
Миссия и журнал (аудит) docs/93-audit/05-mission-and-journal.md