Архитектура симулятора geoscan-sim¶
О чём этот раздел и кому читать. Здесь описано, как устроен headless-симулятор в каталоге simulate/: из каких модулей он собран, с каким шагом модельного времени считает полёт, что попадает в журнал телеметрии и как помечается достоверность выходных чисел. Жюри и заказчик — раздел «Зачем это в хакатоне» и диаграмма контекста. Разработчик — слои кода, последовательность шага симуляции и ссылки на файлы. Оператор — команда прогона, что лежит в каталоге результата и как проверить детерминизм. Соседние продукты: планировщик geoscan, бизнес-смысл симулятора 01-business, API и CLI 04-api (черновик), эксплуатация 05-operate. Детали журнала и fidelity_class — 02-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)¶
- Каждая числовая величина в отчётах должна иметь предка с
fidelity_class,depends_on,validation_level— иначе падает валидация (fidelity/validate.py:38-49). - Телеметрия — все числа в строке наследуют класс
derivedи наборINTEGRATOR_DEPENDS(journal/writer.py:153-157,fidelity/quantity.py:26-31). - Манифест прогона — корень помечается
annotate_artifact_root(journal/writer.py:382-387). - План vs факт — метрики времени/энергии
derived, плановое покрытие без пересчёта в симе может бытьassumed(mission/fact_report.py— см. обёрткиquantityвbuild_plan_vs_fact). - Аудит каталога —
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 headless — 16800 строк в 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.py — seed не читается в цикле |
Полный перечень — LIMITATIONS.md.
Связанные разделы документации¶
| Раздел | Путь |
|---|---|
| Сценарии и акторы симулятора | 02-usecases |
| HTTP, CLI, схемы | 04-api — на доработке |
| Запуск стенда, чтение результатов | 05-operate |
| Требования к миру (исторические) | docs/10-world |
| Миссия и журнал (аудит) | docs/93-audit/05-mission-and-journal.md |