HTTP API симулятора¶
Базовый URL по умолчанию: http://<host>:8080 (sim serve, simulate/src/geoscan_sim/cli/__init__.py:167-168). Префикс бизнес-ручек: /api/v1. Служебные: /healthz, /readyz, /metrics (без префикса).
Спецификация OpenAPI генерируется FastAPI: GET /api/v1/openapi.json (simulate/src/geoscan_sim/api/app.py:49). Снимок для CI: simulate/contracts/openapi.snapshot.json.
Проверка примеров: сессия 2026-09-17. Команды с портом 18081 — локально (sim serve --no-auth, каталог /tmp/sim-api-doc-runs-fresh). Стенд https://aerosim.ff (k3s n1, DNS 100.66.249.6) отвечает на /healthz и /readyz с TLS от ff-ca; все /api/* на стенде требуют Bearer / X-Api-Key (ключ — creds-store, namespace по согласованию с fix4/sim-deploy).
Предусловия для примеров ниже¶
Все curl в этом файле с портом 18081 воспроизводимы «с нуля», если в отдельном терминале (или в фоне) запущен API без ключа:
cd simulate
rm -rf /tmp/sim-api-doc-runs-fresh
mkdir -p /tmp/sim-api-doc-runs-fresh
uv run sim serve --host 127.0.0.1 --port 18081 --no-auth --runs-dir /tmp/sim-api-doc-runs-fresh --workers 2
Дождитесь готовности: curl -sS http://127.0.0.1:18081/healthz → {"status":"ok",...}.
В проде вместо --no-auth задайте ключ: sim serve --api-key <secret> или SIMULATE_API_KEY, и добавьте к каждому запросу под /api/ заголовок Authorization: Bearer <ключ> (или X-Api-Key, см. раздел «Аутентификация»).
Переменные для сценария прогона (обязательны перед любым run-зависимым curl ниже):
export SIM_BASE=http://127.0.0.1:18081
# RUN_ID — см. блок «Сценарий: один прогон» в разделе «Прогоны»
Аутентификация¶
Для всех путей, начинающихся с /api/, требуется сервисный ключ (simulate/src/geoscan_sim/api/auth.py:35-38):
- заголовок
Authorization: Bearer <ключ>; - или
X-Api-Key: <ключ>.
Публичные пути (без ключа): /healthz, /readyz, /metrics (auth.py:16).
| Ситуация | HTTP | code |
|---|---|---|
| Ключ не передан / неверный | 401 | API_KEY_INVALID |
| На сервере не задан ключ (не dev) | 503 | API_KEY_REQUIRED |
Пример (живой ответ, 2026-09-17):
{
"code": "API_KEY_INVALID",
"severity": "fatal",
"path": "Authorization",
"message": "Требуется валидный сервисный ключ (Bearer или X-Api-Key)"
}
Запуск:
sim serve --api-key <secret>или переменнаяSIMULATE_API_KEY(cli/__init__.py:171-175);- без ключа при старте генерируется случайный ключ (stderr);
sim serve --no-auth— отключает проверку (только dev, предупреждение в лог,cli/__init__.py:177-210).
Каждый ответ получает X-Request-Id (simulate/src/geoscan_sim/api/middleware.py:22-30).
Служебные ручки¶
GET /healthz¶
Проверка живости процесса.
Запрос (локально):
curl -sS http://127.0.0.1:18081/healthz
Ответ 200 (локально, 2026-09-17):
{"status":"ok","version":"0.1.0+93d95f12"}
version — engine_version() (simulate/src/geoscan_sim/api/routes/ops.py:12-14); суффикс после + зависит от git-sha образа.
Запрос (стенд aerosim.ff, только tailnet + ff-ca):
curl -sS --cacert ff-ca/ff-ca.crt https://aerosim.ff/healthz
Ответ 200 (2026-09-17):
{"status":"ok","version":"0.1.0+unknown"}
GET /readyz¶
Готовность принимать прогоны: при необходимости стартует пул воркеров (ops.py:17-22).
Запрос:
curl -sS -w '\nHTTP %{http_code}\n' http://127.0.0.1:18081/readyz
Ответ 200:
{"status":"ok"}
GET /metrics¶
Prometheus text format 0.0.4 (ops.py:25-27). Метрики объявлены в simulate/src/geoscan_sim/api/metrics.py:7-19:
| Имя | Тип | Labels | Назначение |
|---|---|---|---|
sim_http_requests_total |
Counter | method, path, status |
Счётчик запросов |
sim_http_request_duration_seconds |
Histogram | method, path |
Латентность |
sim_run_active |
Gauge | — | Прогоны в работе |
sim_run_queue_depth |
Gauge | — | Ожидают воркера |
Запрос (явно вывести gauge очереди и активных прогонов):
curl -sS http://127.0.0.1:18081/metrics | grep -E '^sim_(http_requests_total|run_active|run_queue_depth)'
Фрагмент ответа (2026-09-17):
sim_http_requests_total{method="GET",path="/healthz",status="200"} 3.0
sim_http_requests_total{method="GET",path="/readyz",status="200"} 1.0
sim_run_active 0.0
sim_run_queue_depth 0.0
Дополнительно в выдаче — гистограммы sim_http_request_duration_seconds_* и стандартные метрики prometheus_client (Python GC и т.д.).
GET /api/v1/openapi.json¶
Машиночитаемая схема всех ручек (Swagger UI отключён: app.py:50-51).
Запрос:
curl -sS http://127.0.0.1:18081/api/v1/openapi.json | jq '{title: .info.title, path_count: (.paths | keys | length), sample_paths: (.paths | keys | sort | .[0:5])}'
Ответ (2026-09-17):
{
"title": "Geoscan Simulator API",
"path_count": 17,
"sample_paths": [
"/api/v1/fleet",
"/api/v1/preflight",
"/api/v1/reference",
"/api/v1/runs",
"/api/v1/runs/{run_id}"
]
}
Полный перечень из 17 путей (методы):
| Метод | Путь |
|---|---|
| GET | /healthz, /readyz, /metrics |
| GET | /api/v1/openapi.json |
| GET | /api/v1/fleet, /api/v1/worlds, /api/v1/worlds/{world_id} |
| GET | /api/v1/reference |
| POST | /api/v1/preflight, /api/v1/validate |
| GET, POST | /api/v1/runs |
| GET | /api/v1/runs/{run_id}, .../telemetry, .../events, .../frames, .../coverage, .../scene_layers, .../verdict |
Каталог¶
GET /api/v1/fleet¶
Каталог бортов и нагрузок из YAML (simulate/src/geoscan_sim/api/routes/catalog.py:13-15).
Запрос:
curl -sS http://127.0.0.1:18081/api/v1/fleet | jq '.fleet.uav.geoscan_gemini | {name, type, endurance_max}'
Ответ 200 (фрагмент):
{
"name": "Геоскан Gemini",
"type": "multirotor",
"endurance_max": 40
}
Полное тело: {"fleet": {"uav": {...}, ...}, "payloads": {...}} (модель geoscan_gemini проверяется в tests/test_api.py:60).
GET /api/v1/worlds¶
Список доступных миров (catalog.py:18-20).
Запрос:
curl -sS http://127.0.0.1:18081/api/v1/worlds | jq 'length, .[0]'
Ответ 200: 15 миров; первый элемент (2026-09-17):
{
"world_id": "dem_assumed_low_seed_42",
"kind": "synthetic",
"bbox": [0.0, 0.0, 100.0, 100.0],
"resolution_m": 10.0,
"sha256": "69e1305a984984b0e0dd45f8c17330c33052704daac46fc9bd4306c48c996b60"
}
GET /api/v1/worlds/{world_id}¶
Манифест мира (catalog.py:23-34).
Запрос (существующий мир):
curl -sS http://127.0.0.1:18081/api/v1/worlds/dem_assumed_low_seed_42 | jq '{source_id, world_snapshot_id, layer_ids, layer_completeness, fidelity_class}'
Ответ 200 (фрагмент, 2026-09-17):
{
"source_id": "synthetic",
"world_snapshot_id": "eb5f1fc5b1f82cb2f3b522a8cb426d899ea6b6fb48ec7142eef403b7b913d2ae",
"layer_ids": ["terrain", "surface", "obstacles", "zones", "sites", "weather", "basemap", "quality"],
"layer_completeness": {"obstacles": 0.0, "zones": 0.0},
"fidelity_class": "assumed"
}
Запрос (неизвестный id) — пример 404:
curl -sS -w '\nHTTP %{http_code}\n' http://127.0.0.1:18081/api/v1/worlds/not_a_world
Ответ 404:
{
"code": "WORLD_NOT_FOUND",
"severity": "fatal",
"path": "world_id",
"message": "Неизвестный мир: 'not_a_world'"
}
Справочник валидатора¶
GET /api/v1/reference¶
Таблица эталонов V-1…V-14 (reference.py:10-31, тест test_api.py:76-86).
Запрос:
curl -sS http://127.0.0.1:18081/api/v1/reference | jq 'length, .[0]'
Ответ 200: массив из 14 строк. Первая строка (2026-09-17):
{
"id": "V-1",
"metric": "Gemini endurance calm cruise",
"reference": 40.00921658986175,
"actual": 40.0,
"delta": -0.00921658986175089,
"tolerance": "±5%",
"status": "PASS",
"source": "literal:144.7 Wh / 217 W (fleet.yaml anchors, independent)",
"detail": ""
}
Проверка плана без прогона¶
POST /api/v1/preflight¶
Тело: {"plan": <object>, "strict": <bool>} (preflight.py:16-38).
Запрос:
curl -sS -X POST http://127.0.0.1:18081/api/v1/preflight \
-H 'Content-Type: application/json' \
-d "$(jq -n --slurpfile p fixtures/plans/s01.json '{plan: $p[0], strict: false}')"
(команда из каталога simulate/.)
Ответ 200 (2026-09-17):
{
"codes": [],
"warnings": ["OBSTACLE_LAYER_INCOMPLETE"],
"violations": {}
}
При strict: true включается режим жёстких рамок зон (PreflightContext(strict_zone_frames=strict)).
Ошибки: нет plan — 400; контракт/импорт — 422 с одним объектом ошибки.
POST /api/v1/validate¶
Контракт плана + таблица reference + матрица мутантов (validate.py:17-55). Тело: {"plan": <object>, "scenario": <object>} (поле scenario в обработчике не используется, но допустимо пустым {}).
Запрос:
curl -sS -X POST http://127.0.0.1:18081/api/v1/validate \
-H 'Content-Type: application/json' \
-d "$(jq -n --slurpfile p fixtures/plans/s01.json '{plan: $p[0], scenario: {}}')"
Ответ 200 (сводка, 2026-09-17):
{
"reference_len": 14,
"mutants_head": [
{"mutant_id": "m01", "expected": "NFZ_VIOLATION", "detected": true, "detail": "NFZ_VIOLATION"},
{"mutant_id": "m02", "expected": "CLEARANCE_VIOLATION", "detected": true, "detail": "CLEARANCE_VIOLATION"}
]
}
(получено через jq '{reference_len: (.reference|length), mutants_head: .mutants[0:2]}'; в JSON API ключи верхнего уровня — reference и mutants, полный ответ может занимать десятки килобайт).
Прогоны¶
Каталог прогонов по умолчанию: simulate/data/runs (app.py:43). В примерах ниже — --runs-dir /tmp/sim-api-doc-runs-fresh.
Сценарий: один прогон для всех run-зависимых ручек¶
Выполните этот блок сразу после старта sim serve (см. «Предусловия»). Все последующие curl в разделе используют $RUN_ID — подставьте свой uuid из шага 1, не копируйте uuid из примеров JSON.
export SIM_BASE=http://127.0.0.1:18081
# 1) Создать прогон и сохранить run_id (один POST)
RESP=$(curl -sS -i -X POST "$SIM_BASE/api/v1/runs" \
-H 'Content-Type: application/json' \
-d '{"plan_ref":"s01","world_id":"plane_seed_42","seed":42}')
echo "$RESP" # HTTP 202 + Location
export RUN_ID=$(echo "$RESP" | tail -1 | jq -r .run_id)
# 2) Дождаться done (~20–120 с на plane_seed_42)
until [ "$(curl -sS "$SIM_BASE/api/v1/runs/$RUN_ID" | jq -r .status)" = "done" ] \
|| [ "$(curl -sS "$SIM_BASE/api/v1/runs/$RUN_ID" | jq -r .status)" = "failed" ]; do
sleep 2
done
curl -sS "$SIM_BASE/api/v1/runs/$RUN_ID" | jq '{run_id, status, exit_status}'
В сессии документирования 2026-09-17 шаг 2 завершился за ~18 с (status: done). Фрагменты JSON ниже иллюстрируют структуру ответа; подставьте свой run_id из $RUN_ID, не копируйте uuid из примеров.
GET /api/v1/runs¶
Список прогонов с api_meta.json (run_store.py:100-120).
Запрос (после сценария выше):
curl -sS "$SIM_BASE/api/v1/runs" | jq .
Ответ 200 (фрагмент структуры, 2026-09-17):
[
{
"run_id": "<ваш RUN_ID>",
"status": "done",
"created_at": "2026-09-17T20:41:35.785500+00:00",
"world_id": "plane_seed_42",
"plan_sha256": "51ad0e979bb7ec90a32a38b70392f85f678a2149ec56db1e719b1c065c838792"
}
]
POST /api/v1/runs¶
Создание асинхронного прогона. Ответ 202.
Тело (все поля опциональны, кроме плана):
| Поле | Тип | Описание |
|---|---|---|
plan |
object | JSON плана inline |
plan_ref |
string | s01 → simulate/fixtures/plans/s01.json (run_store.py:84-94) |
world_id |
string | default m0-synthetic-flat |
seed |
int | default 42 |
run_id |
uuid string | явный id; повтор → 409 |
Запрос:
curl -sS -i -X POST "$SIM_BASE/api/v1/runs" \
-H 'Content-Type: application/json' \
-d '{"plan_ref":"s01","world_id":"plane_seed_42","seed":42}'
Ответ (пример заголовков и тела, 2026-09-17):
HTTP/1.1 202 Accepted
Location: /api/v1/runs/<ваш RUN_ID>
{"run_id":"<ваш RUN_ID>","status":"queued"}
Очередь: максимум 32 ожидающих (run_store.py:40, 429 + RUN_QUEUE_FULL).
GET /api/v1/runs/{run_id}¶
Агрегат статуса и артефактов (run_store.py:213-240). Сначала выполните сценарий прогона и задайте $RUN_ID.
Запрос:
curl -sS "$SIM_BASE/api/v1/runs/$RUN_ID" | jq '{run_id, status, exit_status, preflight_codes, fact_metrics: .fact_report.metric_names, makespan_planned: .fact_report.plan_vs_fact.makespan_s.planned.value, makespan_actual: .fact_report.plan_vs_fact.makespan_s.actual.value}'
Ответ 200 (фрагмент, 2026-09-17):
{
"run_id": "<ваш RUN_ID>",
"status": "done",
"exit_status": "completed",
"preflight_codes": [],
"fact_metrics": ["makespan_s", "total_flight_time_s", "coverage_fraction", "energy_used_frac"],
"makespan_planned": 1680.0,
"makespan_actual": 1680.0
}
При failed — manifest / fact_report могут быть null, в error — структурированная ошибка.
GET /api/v1/runs/{run_id}/telemetry¶
Строки из telemetry.jsonl с фильтрами (runs.py:106-132):
| Query | Описание |
|---|---|
uav_id |
фильтр по борту |
from, to |
диапазон t_sim_s |
decimate |
лимит точек (default 20000, runs.py:22) |
Запрос (нужен $RUN_ID из сценария):
curl -sS "$SIM_BASE/api/v1/runs/$RUN_ID/telemetry?decimate=1" | jq '.[0] | {t_sim_s, lon, lat, mode, fidelity_class}'
Ответ 200 (одна точка после decimate=1, 2026-09-17):
{
"t_sim_s": 0.0,
"lon": 37.6173,
"lat": 55.7508,
"mode": "takeoff",
"fidelity_class": "derived"
}
GET /api/v1/runs/{run_id}/events¶
События из events.jsonl; query type — фильтр по полю type (runs.py:135-152).
Запрос:
curl -sS "$SIM_BASE/api/v1/runs/$RUN_ID/events?type=run_start" | jq .
Ответ 200:
[
{
"t_sim_s": 0.0,
"seq": 0,
"type": "run_start",
"uav_id": "gemini-01",
"payload": {"clock_mode": "headless"},
"fidelity_class": "derived",
"validation_level": "V2"
}
]
GET /api/v1/runs/{run_id}/frames¶
Паспорта кадров (runs.py:182-223). При отсутствии frames.jsonl и status=done может вызваться догенерация headless (runs.py:162-176).
Запрос:
curl -sS "$SIM_BASE/api/v1/runs/$RUN_ID/frames?decimate=2" | jq '{status, total_count, returned_count, truncated, first_frame: .frames[0].frame_id}'
Ответ 200 (2026-09-17):
{
"status": "COMPUTED",
"total_count": 380,
"returned_count": 2,
"truncated": true,
"first_frame": "gemini-01-f000000"
}
Default cap кадров: 5000 (runs.py:23).
GET /api/v1/runs/{run_id}/coverage¶
Сводка покрытия из frames_summary.json (runs.py:239-270). Если файла нет — coverage_fraction_actual: null, status: NOT_COMPUTED.
Запрос:
curl -sS "$SIM_BASE/api/v1/runs/$RUN_ID/coverage" | jq .
Ответ 200 (прогон s01 / plane_seed_42, 2026-09-17):
{
"coverage_fraction_actual": 0.299322,
"gsd_grid": {
"max_m": 0.019583,
"mean_m": 0.019583,
"min_m": 0.019583,
"out_of_spec_fraction": 0.0
},
"gap_polygons": []
}
В fact_report.json метрика coverage_fraction для того же плана может оставаться NOT_COMPUTED — это разные слои отчёта (см. journal-reports.md).
GET /api/v1/runs/{run_id}/scene_layers¶
GeoJSON-слои для вьюера (runs.py:226-236, simulate/src/geoscan_sim/api/scene_layers.py).
Запрос:
curl -sS "$SIM_BASE/api/v1/runs/$RUN_ID/scene_layers" | jq 'keys'
Ответ 200:
[
"actual_path",
"link_boundary",
"nfz_volumes",
"planned_path",
"swath_corridor",
"terrain_exaggeration"
]
GET /api/v1/runs/{run_id}/verdict¶
Трёхзначный вердикт не вычисляется — ответ UNRESOLVED с блокерами (verdict.py:12-30).
Запрос:
curl -sS "$SIM_BASE/api/v1/runs/$RUN_ID/verdict" | jq .
Ответ 200 (2026-09-17):
{
"verdict": "UNRESOLVED",
"layer_completeness": {"obstacles": 0.0, "zones": 0.0},
"layer_completeness_score": 0.0,
"blockers": [
{
"code": "LAYER_COMPLETENESS_LOW",
"message": "Полнота слоя мира 0.0000; требуется 1.0 по всем слоям."
}
],
"v3": {
"route_length_ok": true,
"violations_superset_ok": true,
"planned_path_m": 2189.701,
"telemetry_path_m": 2189.118,
"relative_error": 0.000267
}
}
Коды ошибок¶
Формат тела (SIM-FIN-09):
{
"code": "RUN_NOT_FOUND",
"severity": "fatal",
"path": "/api/v1/runs/00000000-0000-4000-8000-000000000099",
"message": "Прогон не найден"
}
Полный реестр: simulate/contracts/error_codes.yaml. Индекс в коде: error_code_index() (loader.py:74-78).
Живые примеры 422 / 404 / 409¶
422 — сломанный план при POST /runs (локально, 2026-09-17):
curl -sS -w '\nHTTP %{http_code}\n' -X POST http://127.0.0.1:18081/api/v1/runs \
-H 'Content-Type: application/json' \
-d '{"plan":{"schema_version":99,"scenario_id":"x","missions":[]},"world_id":"plane_seed_42","seed":1}'
{"code":"PLAN_SCHEMA_VERSION_UNSUPPORTED","severity":"fatal","path":"schema_version","message":"Неподдерживаемая schema_version: 99"}
404 — несуществующий прогон:
curl -sS -w '\nHTTP %{http_code}\n' http://127.0.0.1:18081/api/v1/runs/00000000-0000-4000-8000-000000000099
409 — повтор run_id (два одинаковых POST с тем же run_id и plan):
RID=$(uuidgen)
BODY=$(jq -n --slurpfile p fixtures/plans/s07.json --arg rid "$RID" \
'{run_id: $rid, plan: $p[0], world_id: "plane_seed_42", seed: 42}')
curl -sS -X POST http://127.0.0.1:18081/api/v1/runs -H 'Content-Type: application/json' -d "$BODY"
curl -sS -w '\nHTTP %{http_code}\n' -X POST http://127.0.0.1:18081/api/v1/runs -H 'Content-Type: application/json' -d "$BODY"
{"code":"RUN_ID_COLLISION","severity":"fatal","path":"run_id","message":"Повторная запись журнала с существующим run_id"}
HTTP по сценариям¶
| HTTP | code |
Когда |
|---|---|---|
| 400 | PLAN_SCHEMA_VERSION_UNSUPPORTED |
POST /runs без plan / plan_ref (runs.py:52-58) |
| 400 | WORLD_NOT_FOUND |
неизвестный world_id при создании прогона (runs.py:85-87) |
| 401 | API_KEY_INVALID |
неверный ключ (auth.py:59-65) |
| 404 | RUN_NOT_FOUND |
нет api_meta.json для run_id (runs.py:34-41) |
| 404 | WORLD_NOT_FOUND |
GET /worlds/{id} (catalog.py:27-34) |
| 409 | RUN_ID_COLLISION |
каталог прогона уже существует (runs.py:71-73) |
| 422 | PLAN_SCHEMA_VERSION_UNSUPPORTED |
контракт плана / импорт (runs.py:62-68) |
| 429 | RUN_QUEUE_FULL |
очередь ≥ 32 (runs.py:74-80) |
| 500 | INTERNAL_ERROR |
необработанное исключение (errors.py:46-48) |
| 503 | API_KEY_REQUIRED |
ключ не сконфигурирован (auth.py:51-57) |
Ошибки валидации FastAPI (тело запроса) также мапятся в 422 с PLAN_SCHEMA_VERSION_UNSUPPORTED (errors.py:29-35).
POST /validate и POST /preflight при ошибках бросают HTTPException с detail = dict ошибки (validate.py:21-30, preflight.py:20-29).
Проверка контракта в CI¶
cd simulate && uv run pytest tests/test_api.py -q
Перед запуском остановите фоновые sim serve с воркерами на той же машине: при занятом пуле процессов возможен таймаут test_post_runs_plan_ref (ожидание до 120 с, tests/test_api.py:406-417).
Ключевой сценарий: test_post_runs_s01_completes_with_fact_report — inline plan, мир plane_seed_42, ожидание status == done и наличие fact_report (tests/test_api.py:103-127).