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

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"}

versionengine_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)).

Ошибки: нет plan400; контракт/импорт — 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 s01simulate/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
}

При failedmanifest / 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).