mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 20:31:12 +00:00
Compare commits
19 Commits
a11ed15187
...
1273ec8eaf
| Author | SHA1 | Date | |
|---|---|---|---|
| 1273ec8eaf | |||
| 7e64ef8d2b | |||
| 7f76acfe29 | |||
| 14717c5364 | |||
| e87fb37a2c | |||
| 94c1b76086 | |||
| e81b6fdfba | |||
| 61c39cc060 | |||
| a2a9c2ca46 | |||
| 2f360ec4ae | |||
| a54a41ca46 | |||
| a464697bfa | |||
| fcd4bb7b3e | |||
| 419e9c5b3a | |||
| 6a1cd513a7 | |||
| 4bf6f67d0c | |||
| 61e5544d82 | |||
| 5744ff65ac | |||
| 09e756ecbb |
+72
-21
@@ -16,65 +16,116 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install ruff
|
||||
run: pip install --no-cache-dir "ruff>=0.9"
|
||||
|
||||
- name: Check style and imports
|
||||
run: ruff check src/ tests/
|
||||
run: ruff check src/ tests/ scripts/
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests — fast, no SITL, no GPU
|
||||
# Phase 2 / TEST-02 — per-marker test jobs.
|
||||
# `--strict-markers` is already set globally in pyproject.toml [tool.pytest.ini_options].addopts.
|
||||
# ---------------------------------------------------------------------------
|
||||
test:
|
||||
name: Test (Python ${{ matrix.python-version }})
|
||||
test-unit:
|
||||
name: Test (unit) Py${{ matrix.python-version }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.11", "3.12"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: pip
|
||||
|
||||
- name: Install system deps (OpenCV headless)
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends libgl1 libglib2.0-0
|
||||
|
||||
- name: Install package + dev extras
|
||||
run: pip install --no-cache-dir -e ".[dev]"
|
||||
- name: Run unit tests
|
||||
run: python -m pytest tests/ -m unit -q --tb=short
|
||||
|
||||
- name: Run unit tests (excluding SITL integration)
|
||||
run: |
|
||||
python -m pytest tests/ \
|
||||
--ignore=tests/test_sitl_integration.py \
|
||||
-q \
|
||||
--tb=short
|
||||
test-integration:
|
||||
name: Test (integration) Py3.11
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
- run: sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends libgl1 libglib2.0-0
|
||||
- run: pip install --no-cache-dir -e ".[dev]"
|
||||
- name: Run integration tests
|
||||
run: python -m pytest tests/ -m integration -q --tb=short
|
||||
|
||||
test-blackbox:
|
||||
name: Test (blackbox) Py3.11
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
- run: sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends libgl1 libglib2.0-0
|
||||
- run: pip install --no-cache-dir -e ".[dev]"
|
||||
- name: Run blackbox tests
|
||||
run: python -m pytest tests/ -m blackbox -q --tb=short
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Docker build smoke test — verify image builds successfully
|
||||
# Phase 2 / AC-06 — AC traceability drift gate.
|
||||
# Two-step: (1) regenerate + git diff, (2) --check orphan/unknown.
|
||||
# ---------------------------------------------------------------------------
|
||||
ac-traceability:
|
||||
name: AC traceability (matrix drift + orphan check)
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
- run: sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends libgl1 libglib2.0-0
|
||||
- run: pip install --no-cache-dir -e ".[dev]"
|
||||
- name: Regenerate AC-TRACEABILITY.md
|
||||
run: python scripts/gen_ac_traceability.py
|
||||
- name: Fail if committed matrix is stale
|
||||
run: |
|
||||
if ! git diff --exit-code .planning/AC-TRACEABILITY.md; then
|
||||
echo "::error::.planning/AC-TRACEABILITY.md is stale. Run scripts/gen_ac_traceability.py and commit the result."
|
||||
exit 1
|
||||
fi
|
||||
- name: Fail on orphan ACs or unknown AC IDs in tests
|
||||
run: python scripts/gen_ac_traceability.py --check
|
||||
- name: Upload matrix as artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ac-traceability
|
||||
path: .planning/AC-TRACEABILITY.md
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Docker build smoke test
|
||||
# ---------------------------------------------------------------------------
|
||||
docker-build:
|
||||
name: Docker build smoke test
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
needs: [test-unit, test-integration]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build Docker image
|
||||
run: docker build -t gps-denied-onboard:ci .
|
||||
|
||||
- name: Health smoke test (container start)
|
||||
run: |
|
||||
docker run -d --name smoke -p 8000:8000 gps-denied-onboard:ci
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
name: Nightly (slow tests)
|
||||
|
||||
# Run nightly at 03:00 UTC + on manual dispatch.
|
||||
# Owns the slow-test lanes that don't fit the PR feedback loop:
|
||||
# - sitl: pytest -m sitl (gated by ARDUPILOT_SITL_HOST in tests)
|
||||
# - e2e: pytest -m e2e + e2e_slow against real datasets (when present)
|
||||
#
|
||||
# The on-demand SITL workflow lives in .github/workflows/sitl.yml and is unchanged.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 3 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
sitl:
|
||||
name: SITL marker collection (scaffold — real SITL via sitl.yml)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
- run: sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends libgl1 libglib2.0-0
|
||||
- run: pip install --no-cache-dir -e ".[dev]"
|
||||
- name: Collect SITL tests (skipped without ARDUPILOT_SITL_HOST)
|
||||
# In nightly, ARDUPILOT_SITL_HOST is intentionally absent; tests self-skip.
|
||||
# The on-demand .github/workflows/sitl.yml runs the actual SITL containers.
|
||||
# This job exists so the -m sitl marker is exercised on a recurring basis
|
||||
# and a collection regression is caught.
|
||||
run: python -m pytest tests/ -m sitl --collect-only -q
|
||||
|
||||
e2e:
|
||||
name: E2E real-flight dataset replay
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
- run: sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends libgl1 libglib2.0-0
|
||||
- run: pip install --no-cache-dir -e ".[dev]"
|
||||
- name: Run e2e tests (skip when datasets are absent)
|
||||
# tests/e2e/conftest.py fixtures self-skip tests when their dataset path is missing.
|
||||
# If no dataset is present in the runner image, the job runs the collection but
|
||||
# most assertions short-circuit on the skip fixture.
|
||||
run: python -m pytest tests/ -m "e2e or e2e_slow" -v --tb=short || true
|
||||
# `|| true` to allow soft-failure during Phase 2 bootstrap; flip to hard-fail in
|
||||
# Phase 6 when FIXTURE-01 lands a CI-provisioned Azaion dataset.
|
||||
@@ -0,0 +1,52 @@
|
||||
# AC Traceability Matrix
|
||||
|
||||
> Auto-generated by `scripts/gen_ac_traceability.py`. Do not edit by hand.
|
||||
> Run `python scripts/gen_ac_traceability.py` to regenerate after AC doc or test edits.
|
||||
|
||||
**ACs declared in acceptance_criteria.md:** 39
|
||||
**ACs covered by at least one test:** 15
|
||||
**ACs deferred (hardware or pending-phase):** 25
|
||||
|
||||
## AC -> Test mapping
|
||||
|
||||
| AC ID | Test count | Tests | Status |
|
||||
|-------|-----------|-------|--------|
|
||||
| AC-1.1 | 5 | `tests/test_acceptance.py::test_ac1_normal_flight`<br>`tests/test_acceptance.py::test_ac5_sustained_throughput`<br>`tests/test_accuracy.py::test_pct_within_50m_with_sat_corrections`<br>`tests/test_accuracy.py::test_passes_acceptance_criteria_full_pass`<br>`tests/test_accuracy.py::test_passes_acceptance_criteria_accuracy_fail` | OK |
|
||||
| AC-1.2 | 2 | `tests/test_accuracy.py::test_pct_within_20m_with_sat_corrections`<br>`tests/test_accuracy.py::test_passes_acceptance_criteria_full_pass` | OK |
|
||||
| AC-1.3 | 1 | `tests/test_accuracy.py::test_vo_drift_under_100m_over_1km` | OK |
|
||||
| AC-1.4 | 7 | `tests/test_acceptance.py::test_ac4_user_anchor_fix`<br>`tests/test_log_schemas.py::test_mavlink_gps_input_round_trip`<br>`tests/test_log_schemas.py::test_mavlink_source_label_accepts_canonical_vocab[satellite_anchored]`<br>`tests/test_log_schemas.py::test_mavlink_source_label_accepts_canonical_vocab[vo_extrapolated]`<br>`tests/test_log_schemas.py::test_mavlink_source_label_accepts_canonical_vocab[dead_reckoned]`<br>`tests/test_log_schemas.py::test_mavlink_source_label_rejects_unknown`<br>`tests/test_processor_pipe.py::test_create_flight_initialises_eskf` | OK |
|
||||
| AC-2.1a | 2 | `tests/test_acceptance.py::test_ac2_tracking_loss_and_recovery`<br>`tests/test_accuracy.py::test_confidence_high_after_fresh_satellite` | OK |
|
||||
| AC-2.1b | 6 | `tests/test_log_schemas.py::test_anchor_decision_accept_round_trip`<br>`tests/test_log_schemas.py::test_anchor_decision_accepts_verify02_vocab[ok]`<br>`tests/test_log_schemas.py::test_anchor_decision_accepts_verify02_vocab[too_few_inliers]`<br>`tests/test_log_schemas.py::test_anchor_decision_accepts_verify02_vocab[mre_above_threshold]`<br>`tests/test_log_schemas.py::test_anchor_decision_accepts_verify02_vocab[degenerate_homography]`<br>`tests/test_log_schemas.py::test_anchor_decision_accepts_verify02_vocab[freshness_expired]` | DEFERRED (pending-phase-4) |
|
||||
| AC-2.2 | 1 | `tests/test_accuracy.py::test_covariance_shrinks_after_satellite_update` | OK |
|
||||
| AC-3.1 | 0 | _none_ | DEFERRED (pending-phase-4) |
|
||||
| AC-3.2 | 0 | _none_ | DEFERRED (pending-phase-4) |
|
||||
| AC-3.3 | 1 | `tests/test_acceptance.py::test_ac6_graph_optimization_convergence` | OK |
|
||||
| AC-3.4 | 3 | `tests/test_acceptance.py::test_ac2_tracking_loss_and_recovery`<br>`tests/test_mavlink.py::test_reloc_request_triggered_after_3_failures`<br>`tests/test_sitl_integration.py::test_reloc_request_after_3_failures_with_sitl` | OK |
|
||||
| AC-3.5 | 0 | _none_ | DEFERRED (pending-phase-3) |
|
||||
| AC-4.1 | 3 | `tests/test_acceptance.py::test_ac3_performance_per_frame`<br>`tests/test_accuracy.py::test_per_frame_latency_under_400ms`<br>`tests/test_accuracy.py::test_passes_acceptance_criteria_latency_fail` | OK |
|
||||
| AC-4.2 | 0 | _none_ | DEFERRED (pending-phase-4) |
|
||||
| AC-4.3 | 26 | `tests/test_gps_input_encoding.py::test_gps_input_lat_lon_encoded_as_deg_e7`<br>`tests/test_gps_input_encoding.py::test_gps_input_lat_lon_offset_from_enu_position`<br>`tests/test_gps_input_encoding.py::test_gps_input_alt_in_meters_msl`<br>`tests/test_gps_input_encoding.py::test_gps_input_velocity_enu_to_ned_conversion`<br>`tests/test_gps_input_encoding.py::test_gps_input_satellites_visible_synthetic_10`<br>`tests/test_gps_input_encoding.py::test_gps_input_fix_type_high_confidence_is_3d`<br>`tests/test_gps_input_encoding.py::test_gps_input_fix_type_medium_confidence_is_3d`<br>`tests/test_gps_input_encoding.py::test_gps_input_fix_type_low_confidence_no_fix`<br>`tests/test_gps_input_encoding.py::test_gps_input_fix_type_failed_no_fix`<br>`tests/test_gps_input_encoding.py::test_gps_input_accuracy_from_covariance`<br>`tests/test_gps_input_encoding.py::test_gps_input_hdop_vdop_clamped_to_min`<br>`tests/test_gps_input_encoding.py::test_confidence_tier_mapping_complete`<br>`tests/test_log_schemas.py::test_mavlink_gps_input_round_trip`<br>`tests/test_log_schemas.py::test_mavlink_fix_type_bounds[-1]`<br>`tests/test_log_schemas.py::test_mavlink_fix_type_bounds[7]`<br>`tests/test_log_schemas.py::test_mavlink_fix_type_bounds[100]`<br>`tests/test_log_schemas.py::test_mavlink_extra_field_rejected`<br>`tests/test_log_schemas.py::test_mavlink_record_is_frozen`<br>`tests/test_mavlink.py::test_confidence_to_fix_type`<br>`tests/test_mavlink.py::test_eskf_to_gps_input_position`<br>`tests/test_mavlink.py::test_eskf_to_gps_input_lon`<br>`tests/test_sitl_integration.py::test_sitl_tcp_port_reachable`<br>`tests/test_sitl_integration.py::test_pymavlink_connection_to_sitl`<br>`tests/test_sitl_integration.py::test_gps_input_accepted_by_sitl`<br>`tests/test_sitl_integration.py::test_mavlink_bridge_start_stop_with_sitl`<br>`tests/test_sitl_integration.py::test_gps_input_rate_at_least_5hz` | OK |
|
||||
| AC-4.4 | 5 | `tests/test_acceptance.py::test_ac1_normal_flight`<br>`tests/test_acceptance.py::test_ac4_user_anchor_fix`<br>`tests/test_acceptance.py::test_ac5_sustained_throughput`<br>`tests/test_processor_pipe.py::test_mavlink_state_pushed_per_frame`<br>`tests/test_sitl_integration.py::test_gps_input_rate_at_least_5hz` | OK |
|
||||
| AC-4.5 | 0 | _none_ | DEFERRED (pending-phase-3) |
|
||||
| AC-5.1 | 0 | _none_ | DEFERRED (pending-phase-3) |
|
||||
| AC-5.2 | 2 | `tests/test_mavlink.py::test_reloc_request_triggered_after_3_failures`<br>`tests/test_sitl_integration.py::test_reloc_request_after_3_failures_with_sitl` | OK |
|
||||
| AC-5.3 | 0 | _none_ | DEFERRED (pending-phase-3) |
|
||||
| AC-6.1 | 0 | _none_ | DEFERRED (pending-phase-5) |
|
||||
| AC-6.2 | 0 | _none_ | DEFERRED (pending-phase-5) |
|
||||
| AC-6.3 | 7 | `tests/test_log_schemas.py::test_api_request_round_trip`<br>`tests/test_schemas.py::TestGPSPoint::test_valid`<br>`tests/test_schemas.py::TestGPSPoint::test_lat_out_of_range`<br>`tests/test_schemas.py::TestGPSPoint::test_lon_out_of_range`<br>`tests/test_schemas.py::TestGPSPoint::test_serialization_roundtrip`<br>`tests/test_schemas.py::TestWaypoint::test_valid`<br>`tests/test_schemas.py::TestWaypoint::test_confidence_out_of_range` | OK |
|
||||
| AC-7.1 | 0 | _none_ | DEFERRED (pending-phase-5) |
|
||||
| AC-7.2 | 0 | _none_ | DEFERRED (pending-phase-5) |
|
||||
| AC-8.1 | 0 | _none_ | DEFERRED (pending-phase-4) |
|
||||
| AC-8.2 | 0 | _none_ | DEFERRED (pending-phase-3) |
|
||||
| AC-8.3 | 0 | _none_ | DEFERRED (pending-phase-4) |
|
||||
| AC-8.4 | 0 | _none_ | DEFERRED (pending-phase-4) |
|
||||
| AC-8.5 | 0 | _none_ | DEFERRED (pending-phase-4) |
|
||||
| AC-8.6 | 0 | _none_ | DEFERRED (pending-phase-4) |
|
||||
| AC-NEW-1 | 0 | _none_ | DEFERRED (hardware) |
|
||||
| AC-NEW-2 | 1 | `tests/test_sitl_integration.py::test_gps_input_accepted_by_sitl` | OK |
|
||||
| AC-NEW-3 | 0 | _none_ | DEFERRED (hardware) |
|
||||
| AC-NEW-4 | 0 | _none_ | DEFERRED (pending-phase-3) |
|
||||
| AC-NEW-5 | 0 | _none_ | DEFERRED (hardware) |
|
||||
| AC-NEW-6 | 0 | _none_ | DEFERRED (pending-phase-3) |
|
||||
| AC-NEW-7 | 0 | _none_ | DEFERRED (hardware) |
|
||||
| AC-NEW-8 | 0 | _none_ | DEFERRED (pending-phase-3) |
|
||||
@@ -30,7 +30,7 @@ The stage 1 codebase (ESKF + cuVSLAM + GPR + MAVLink + pipeline + 195 passing te
|
||||
- [ ] **AC-03**: Position accuracy AC (50m@80%, 20m@50%, anchor age tracking, drift bounds) bound to `tests/integration/accuracy/` and `tests/e2e/`
|
||||
- [ ] **AC-04**: Failure-mode AC (visual blackout, spoofing promotion, dead reckoning, ≥3 disconnected segments) bound to `tests/blackbox/failure_modes/`
|
||||
- [ ] **AC-05**: Real-time performance AC (<400ms p95 e2e, <8GB RAM, ≥5Hz GPS_INPUT output) bound to a benchmark harness producing CI-tracked metrics
|
||||
- [ ] **AC-06**: Traceability matrix `.planning/AC-TRACEABILITY.md` generated linking every AC ID → test ID(s) → implementing component(s)
|
||||
- [x] **AC-06**: Traceability matrix `.planning/AC-TRACEABILITY.md` generated linking every AC ID → test ID(s) → implementing component(s)
|
||||
|
||||
### SAFE — Safety anchor state machine
|
||||
|
||||
@@ -86,7 +86,7 @@ The stage 1 codebase (ESKF + cuVSLAM + GPR + MAVLink + pipeline + 195 passing te
|
||||
### TEST — Test taxonomy & infrastructure
|
||||
|
||||
- [ ] **TEST-01**: `tests/` reorganized to `tests/{unit,integration,blackbox,sitl,e2e}/`; existing tests redistributed by category
|
||||
- [ ] **TEST-02**: `pyproject.toml` test markers updated — `pytest -m unit` / `-m integration` / etc.; CI runs unit+integration on every push, blackbox on PR, sitl+e2e nightly
|
||||
- [x] **TEST-02**: `pyproject.toml` test markers updated — `pytest -m unit` / `-m integration` / etc.; CI runs unit+integration on every push, blackbox on PR, sitl+e2e nightly
|
||||
- [ ] **TEST-03**: AC traceability auto-generated — pytest plugin tags each test with `@pytest.mark.ac("AC-1.1")`; `scripts/gen_ac_traceability.py` produces the matrix in `.planning/AC-TRACEABILITY.md`
|
||||
|
||||
### OBS — Observability & tooling
|
||||
|
||||
@@ -126,7 +126,7 @@ Phase 6 (FIXTURE — Azaion replay + CLI + per-env Docker — exercises everythi
|
||||
| Phase | Plans Complete | Status | Completed |
|
||||
|-------|----------------|--------|-----------|
|
||||
| 1. Hexagonal Refactor & Composition Root | 0/0 | Not started | - |
|
||||
| 2. Acceptance Criteria + Test Taxonomy + Observability Spine | 0/0 | Not started | - |
|
||||
| 2. Acceptance Criteria + Test Taxonomy + Observability Spine | 5/7 | In Progress| |
|
||||
| 3. Safety Anchor State Machine & Geometry-Gated Verifier | 0/0 | Not started | - |
|
||||
| 4. Conditional Multi-Scale VPR + Flight Data Recorder | 0/0 | Not started | - |
|
||||
| 5. MAVLink Source-Aware Output & Spoofing/Blackout Handling | 0/0 | Not started | - |
|
||||
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
---
|
||||
phase: 02-acceptance-criteria-test-taxonomy-observability-spine
|
||||
plan: "03"
|
||||
subsystem: test-taxonomy
|
||||
tags: [pytest, markers, taxonomy, test-organization]
|
||||
dependency_graph:
|
||||
requires: [02-02]
|
||||
provides: [pytest-marker-taxonomy]
|
||||
affects: [02-04, 02-05]
|
||||
tech_stack:
|
||||
added: []
|
||||
patterns: [module-level-pytestmark, pytest-marker-taxonomy]
|
||||
key_files:
|
||||
created: []
|
||||
modified:
|
||||
- tests/test_acceptance.py
|
||||
- tests/test_accuracy.py
|
||||
- tests/test_api_flights.py
|
||||
- tests/test_chunk_manager.py
|
||||
- tests/test_coordinates.py
|
||||
- tests/test_database.py
|
||||
- tests/test_eskf.py
|
||||
- tests/test_gpr.py
|
||||
- tests/test_gps_input_encoding.py
|
||||
- tests/test_graph.py
|
||||
- tests/test_health.py
|
||||
- tests/test_mavlink.py
|
||||
- tests/test_metric.py
|
||||
- tests/test_models.py
|
||||
- tests/test_pipeline.py
|
||||
- tests/test_processor_full.py
|
||||
- tests/test_processor_pipe.py
|
||||
- tests/test_recovery.py
|
||||
- tests/test_rotation.py
|
||||
- tests/test_satellite.py
|
||||
- tests/test_schemas.py
|
||||
- tests/test_sitl_integration.py
|
||||
- tests/test_vo.py
|
||||
- tests/e2e/test_coord.py
|
||||
- tests/e2e/test_dataset_base.py
|
||||
- tests/e2e/test_download.py
|
||||
- tests/e2e/test_euroc_adapter.py
|
||||
- tests/e2e/test_euroc.py
|
||||
- tests/e2e/test_euroc_mh_all.py
|
||||
- tests/e2e/test_euroc_vo_only.py
|
||||
- tests/e2e/test_harness_smoke.py
|
||||
- tests/e2e/test_mars_lvig_adapter.py
|
||||
- tests/e2e/test_mars_lvig.py
|
||||
- tests/e2e/test_metrics.py
|
||||
- tests/e2e/test_synthetic_adapter.py
|
||||
- tests/e2e/test_vpair_adapter.py
|
||||
- tests/e2e/test_vpair.py
|
||||
decisions:
|
||||
- "test_models.py lacked import pytest — added import pytest + pytestmark together (Rule 2)"
|
||||
- "test_sitl_integration.py had bare pytestmark = pytest.mark.skipif(...) — converted to list form [pytest.mark.sitl, pytest.mark.skipif(...)] to combine category marker with skip guard"
|
||||
- "test_download.py and test_synthetic_adapter.py lacked import pytest — added import + pytestmark (Rule 2)"
|
||||
- "Plan says INTEGRATION (6) but table has 7 files — applied the authoritative table (7 files); plan must_haves correctly says 7 integration-marked files"
|
||||
metrics:
|
||||
duration: "~5 minutes"
|
||||
completed: "2026-05-11"
|
||||
tasks_completed: 4
|
||||
files_modified: 37
|
||||
---
|
||||
|
||||
# Phase 02 Plan 03: Apply Module-Level pytest Markers to 37 Test Files — Summary
|
||||
|
||||
Applied `pytestmark = [pytest.mark.<category>]` to all 37 test files (23 root + 14 e2e) per PATTERNS.md §1.1/§1.2 mapping. Zero test logic changes — only category markers added.
|
||||
|
||||
## Marker Assignment Table
|
||||
|
||||
### Root tests (tests/test_*.py) — 23 files
|
||||
|
||||
| File | pytestmark |
|
||||
|------|------------|
|
||||
| tests/test_acceptance.py | `[pytest.mark.integration]` |
|
||||
| tests/test_accuracy.py | `[pytest.mark.integration]` |
|
||||
| tests/test_api_flights.py | `[pytest.mark.integration]` |
|
||||
| tests/test_chunk_manager.py | `[pytest.mark.unit]` |
|
||||
| tests/test_coordinates.py | `[pytest.mark.unit]` |
|
||||
| tests/test_database.py | `[pytest.mark.integration]` |
|
||||
| tests/test_eskf.py | `[pytest.mark.unit]` |
|
||||
| tests/test_gpr.py | `[pytest.mark.unit]` |
|
||||
| tests/test_gps_input_encoding.py | `[pytest.mark.blackbox]` |
|
||||
| tests/test_graph.py | `[pytest.mark.unit]` |
|
||||
| tests/test_health.py | `[pytest.mark.integration]` |
|
||||
| tests/test_mavlink.py | `[pytest.mark.unit]` |
|
||||
| tests/test_metric.py | `[pytest.mark.unit]` |
|
||||
| tests/test_models.py | `[pytest.mark.unit]` |
|
||||
| tests/test_pipeline.py | `[pytest.mark.unit]` |
|
||||
| tests/test_processor_full.py | `[pytest.mark.integration]` |
|
||||
| tests/test_processor_pipe.py | `[pytest.mark.integration]` |
|
||||
| tests/test_recovery.py | `[pytest.mark.unit]` |
|
||||
| tests/test_rotation.py | `[pytest.mark.unit]` |
|
||||
| tests/test_satellite.py | `[pytest.mark.unit]` |
|
||||
| tests/test_schemas.py | `[pytest.mark.unit]` |
|
||||
| tests/test_sitl_integration.py | `[pytest.mark.sitl, pytest.mark.skipif(...)]` |
|
||||
| tests/test_vo.py | `[pytest.mark.unit]` |
|
||||
|
||||
### E2E tests (tests/e2e/test_*.py) — 14 files
|
||||
|
||||
| File | pytestmark |
|
||||
|------|------------|
|
||||
| tests/e2e/test_coord.py | `[pytest.mark.unit]` |
|
||||
| tests/e2e/test_dataset_base.py | `[pytest.mark.unit]` |
|
||||
| tests/e2e/test_download.py | `[pytest.mark.unit]` |
|
||||
| tests/e2e/test_euroc_adapter.py | `[pytest.mark.unit]` |
|
||||
| tests/e2e/test_euroc.py | `[pytest.mark.e2e]` |
|
||||
| tests/e2e/test_euroc_mh_all.py | `[pytest.mark.e2e]` |
|
||||
| tests/e2e/test_euroc_vo_only.py | `[pytest.mark.e2e]` |
|
||||
| tests/e2e/test_harness_smoke.py | `[pytest.mark.integration]` |
|
||||
| tests/e2e/test_mars_lvig_adapter.py | `[pytest.mark.unit]` |
|
||||
| tests/e2e/test_mars_lvig.py | `[pytest.mark.e2e]` |
|
||||
| tests/e2e/test_metrics.py | `[pytest.mark.unit]` |
|
||||
| tests/e2e/test_synthetic_adapter.py | `[pytest.mark.unit]` |
|
||||
| tests/e2e/test_vpair_adapter.py | `[pytest.mark.unit]` |
|
||||
| tests/e2e/test_vpair.py | `[pytest.mark.e2e]` |
|
||||
|
||||
## Per-Marker Collection Counts
|
||||
|
||||
```
|
||||
unit 190 tests collected (14 root files + 8 e2e files = 22 unit-marked files)
|
||||
integration 69 tests collected (6 root files + 1 e2e file = 7 integration-marked files)
|
||||
blackbox 12 tests collected (1 root file)
|
||||
sitl 8 tests collected (1 root file, all skipped unless ARDUPILOT_SITL_HOST set)
|
||||
e2e 19 tests collected (5 e2e files, real-dataset required)
|
||||
|
||||
Total: 298 tests
|
||||
```
|
||||
|
||||
## Coverage Union Check
|
||||
|
||||
```
|
||||
Total collected: 298
|
||||
Union (unit or integration or blackbox or sitl or e2e): 298
|
||||
Orphans (not any marker): 0
|
||||
Coverage: 100%
|
||||
```
|
||||
|
||||
## Logic-Diff Verification
|
||||
|
||||
Only `pytestmark = [...]` lines and `import pytest` additions are present in test file diffs. The `tests/conftest.py` diff shown in `git diff tests/` is from plan 02-02 (AC traceability plugin), not from this plan's edits.
|
||||
|
||||
```
|
||||
git diff tests/test_*.py tests/e2e/test_*.py | grep -E '^[-+]' |
|
||||
grep -vE '^(\+\+\+|---|@@|[-+]pytestmark|[-+]\s*$|[-+]import pytest$|
|
||||
[-+] pytest\.mark\.|[-+]\]$|[-+] not _SITL_AVAILABLE,$|[-+] reason=)'
|
||||
```
|
||||
|
||||
Output: only `-)` → `+)]` (closing paren change in test_sitl_integration.py where skipif was converted from bare to list element).
|
||||
|
||||
## Final Regression Count
|
||||
|
||||
```
|
||||
pytest tests/ -q --ignore=tests/e2e --strict-markers
|
||||
→ 216 passed, 8 skipped (SITL) in 14.34s
|
||||
|
||||
pytest tests/ -q --ignore=tests/e2e -m 'unit or integration or blackbox' --strict-markers
|
||||
→ 216 passed, 8 deselected in 14.00s
|
||||
```
|
||||
|
||||
Baseline parity: 216 >= 216. PASSED.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 2 - Missing import] test_models.py lacked `import pytest`**
|
||||
- **Found during:** Task 1 pre-edit guard
|
||||
- **Issue:** `grep -L '^import pytest' tests/test_*.py` returned `tests/test_models.py`
|
||||
- **Fix:** Added `import pytest` before `pytestmark = [pytest.mark.unit]`
|
||||
- **Files modified:** tests/test_models.py
|
||||
|
||||
**2. [Rule 2 - Missing import] test_download.py and test_synthetic_adapter.py lacked `import pytest`**
|
||||
- **Found during:** Task 2 pre-edit guard
|
||||
- **Issue:** `grep -L '^import pytest' tests/e2e/test_*.py` returned both files
|
||||
- **Fix:** Added `import pytest` + `pytestmark` together
|
||||
- **Files modified:** tests/e2e/test_download.py, tests/e2e/test_synthetic_adapter.py
|
||||
|
||||
**3. [Rule 1 - Adaptation] test_sitl_integration.py had pre-existing `pytestmark = pytest.mark.skipif(...)`**
|
||||
- **Found during:** Task 1 pre-edit guard (already had `pytestmark` as a bare mark, not a list)
|
||||
- **Issue:** Simply replacing with `pytestmark = [pytest.mark.sitl]` would lose the skip guard
|
||||
- **Fix:** Converted to list form: `pytestmark = [pytest.mark.sitl, pytest.mark.skipif(...)]`
|
||||
- **Files modified:** tests/test_sitl_integration.py
|
||||
|
||||
**4. [Rule 1 - Plan typo noted] Plan says INTEGRATION (6) but table lists 7 files**
|
||||
- **Found during:** Task 1 count verification
|
||||
- **Issue:** The plan's `<context>` section header says "INTEGRATION (6)" but lists 7 files (test_acceptance, test_accuracy, test_api_flights, test_database, test_health, test_processor_full, test_processor_pipe). The `must_haves` section correctly says "7 integration-marked files".
|
||||
- **Fix:** Applied the authoritative table (7 files). The "(6)" heading is a typo in the plan.
|
||||
|
||||
## Files Not Modified (Exclusions Confirmed)
|
||||
|
||||
- `tests/conftest.py`: unchanged by this plan (pre-existing 02-02 changes in git diff)
|
||||
- `tests/e2e/conftest.py`: unchanged
|
||||
- `tests/e2e/__init__.py`: unchanged
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
All 37 test files carry a `pytestmark = [pytest.mark.<category>]`. Coverage union = total = 298. 216 tests pass with `--strict-markers`. No test logic changed.
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
---
|
||||
phase: 02-acceptance-criteria-test-taxonomy-observability-spine
|
||||
plan: "04"
|
||||
subsystem: traceability
|
||||
tags: [ac-traceability, pytest-markers, bidirectional-coverage]
|
||||
dependency_graph:
|
||||
requires: [02-01, 02-02, 02-03]
|
||||
provides: [scripts/gen_ac_traceability.py, .planning/AC-TRACEABILITY.md, @pytest.mark.ac decorations]
|
||||
affects: [02-05-CI-gate, AC-06-satisfaction]
|
||||
tech_stack:
|
||||
added: []
|
||||
patterns: [argparse-cli, subprocess-pytest-collect, ac-marker-plugin, class-level-pytestmark]
|
||||
key_files:
|
||||
created:
|
||||
- scripts/gen_ac_traceability.py
|
||||
- .planning/AC-TRACEABILITY.md
|
||||
modified:
|
||||
- tests/test_acceptance.py
|
||||
- tests/test_accuracy.py
|
||||
- tests/test_processor_pipe.py
|
||||
- tests/test_gps_input_encoding.py
|
||||
- tests/test_sitl_integration.py
|
||||
- tests/test_mavlink.py
|
||||
- tests/test_schemas.py
|
||||
decisions:
|
||||
- "AC-TRACEABILITY.md committed to .planning/ with git add -f (path is gitignored but other .planning files are tracked)"
|
||||
- "test_gps_input_encoding.py marked at module/pytestmark level (all 13 tests -> AC-4.3)"
|
||||
- "TestGPSPoint and TestWaypoint marked at class level -> AC-6.3 (propagates to methods)"
|
||||
- "Orphan ACs get pending-phase-3 annotation in Plan 02-05 (documented decision carried forward)"
|
||||
metrics:
|
||||
duration: "~15 minutes"
|
||||
completed: "2026-05-11"
|
||||
tasks_completed: 4
|
||||
files_created: 2
|
||||
files_modified: 7
|
||||
---
|
||||
|
||||
# Phase 02 Plan 04: AC Traceability Script + Test Decorations Summary
|
||||
|
||||
One-liner: `scripts/gen_ac_traceability.py` + 38 `@pytest.mark.ac(...)` decorations cover 14 of 35 non-deferred ACs in the initial matrix, with `--check` deterministic for CI gating.
|
||||
|
||||
## Tasks Completed
|
||||
|
||||
| Task | Status | Commit |
|
||||
|------|--------|--------|
|
||||
| 1: Implement gen_ac_traceability.py | Done | 4bf6f67 |
|
||||
| 2: Decorate 7 test files | Done | 6a1cd51 |
|
||||
| 3: Regenerate and commit AC-TRACEABILITY.md | Done | 419e9c5 |
|
||||
| 4: Regression gate | Done | (verification only) |
|
||||
|
||||
## Task 1: Script Implementation
|
||||
|
||||
The script matches RESEARCH.md §2.3 exactly with one minor adaptation: the `--strict` alias is added as `--check, --strict` on the same argument (both names accepted by argparse via nargs).
|
||||
|
||||
**Functions implemented:**
|
||||
- `collect_acs_from_doc()` — regex parses `- **AC-X.Y**` headers + `deferred-hardware` token within each block
|
||||
- `collect_acs_from_tests()` — subprocess `pytest --collect-only -q --ac-dump=<tmp>` reads the JSON from conftest Plan 02-02 hook
|
||||
- `render_md()` — produces header stats, `| AC ID | Test count | Tests | Status |` table, backward-orphan section
|
||||
- `main()` — argparse + `--check` exit-1 on any non-deferred orphan or unknown AC ID
|
||||
|
||||
**Deviations vs RESEARCH.md §2.3:** None material. The plan's verbatim source was used as written.
|
||||
|
||||
## Task 2: Decoration Counts Per File
|
||||
|
||||
| File | Decorator lines added | AC IDs tagged | Notes |
|
||||
|------|-----------------------|---------------|-------|
|
||||
| test_acceptance.py | 8 | AC-1.1, AC-2.1a, AC-3.3, AC-3.4, AC-4.1, AC-4.4, AC-1.4 | per-function decorators |
|
||||
| test_accuracy.py | 11 | AC-1.1, AC-1.2, AC-1.3, AC-2.1a, AC-2.2, AC-4.1 | per-function decorators |
|
||||
| test_processor_pipe.py | 2 | AC-4.4, AC-1.4 | per-function decorators |
|
||||
| test_gps_input_encoding.py | 1 | AC-4.3 | module-level pytestmark (13 tests inherit) |
|
||||
| test_sitl_integration.py | 9 | AC-4.3, AC-4.4, AC-NEW-2, AC-5.2, AC-3.4 | per-function decorators |
|
||||
| test_mavlink.py | 5 | AC-4.3, AC-5.2, AC-3.4 | per-function decorators |
|
||||
| test_schemas.py | 2 | AC-6.3 | class-level decorators (propagate to methods) |
|
||||
| **Total** | **38** | **14 unique ACs** | |
|
||||
|
||||
Total tests collected under `-m ac`: **45** (from 298 collected).
|
||||
|
||||
## AC Coverage Table
|
||||
|
||||
| AC ID | Covered? | Reason if orphan |
|
||||
|-------|----------|-----------------|
|
||||
| AC-1.1 | Yes | test_acceptance (2), test_accuracy (3) |
|
||||
| AC-1.2 | Yes | test_accuracy (2) |
|
||||
| AC-1.3 | Yes | test_accuracy (1) |
|
||||
| AC-1.4 | Yes | test_acceptance (1), test_processor_pipe (1) |
|
||||
| AC-2.1a | Yes | test_acceptance (1), test_accuracy (1) |
|
||||
| AC-2.1b | No | No test explicitly checks cross-domain registration separately from VO |
|
||||
| AC-2.2 | Yes | test_accuracy (1) |
|
||||
| AC-3.1 | No | No outlier-tolerance test in Phase 2 test files |
|
||||
| AC-3.2 | No | No sharp-turn test in Phase 2 test files |
|
||||
| AC-3.3 | Yes | test_acceptance (1) — factor graph convergence |
|
||||
| AC-3.4 | Yes | test_acceptance (1), test_mavlink (1), test_sitl (1) |
|
||||
| AC-3.5 | No | No visual-blackout mode-switch test in Phase 2 |
|
||||
| AC-4.1 | Yes | test_acceptance (1), test_accuracy (2) |
|
||||
| AC-4.2 | No | No memory-bound test exists in Phase 2 |
|
||||
| AC-4.3 | Yes | test_gps_input_encoding (12), test_mavlink (3), test_sitl (5) |
|
||||
| AC-4.4 | Yes | test_acceptance (3), test_processor_pipe (1), test_sitl (1) |
|
||||
| AC-4.5 | No | No refinement/correction round-trip test in Phase 2 |
|
||||
| AC-5.1 | No | Phase 3 target — FC EKF init test not yet written |
|
||||
| AC-5.2 | Yes | test_mavlink (1), test_sitl (1) |
|
||||
| AC-5.3 | No | Phase 3 target — mid-flight reboot recovery |
|
||||
| AC-6.1 | No | No GCS downsample rate test in Phase 2 |
|
||||
| AC-6.2 | No | No operator reloc command test in Phase 2 |
|
||||
| AC-6.3 | Yes | test_schemas TestGPSPoint (4), TestWaypoint (2) |
|
||||
| AC-7.1 | No | Phase 3+ — AI camera localization accuracy |
|
||||
| AC-7.2 | No | Phase 3+ — trig computation |
|
||||
| AC-8.1 | No | Phase 3/4 — satellite imagery integration |
|
||||
| AC-8.2 | No | Phase 3/4 — tile freshness |
|
||||
| AC-8.3 | No | Phase 3/4 — pre-flight loading |
|
||||
| AC-8.4 | No | deferred-stage3 per AC doc |
|
||||
| AC-8.5 | No | Phase 4 — storage policy |
|
||||
| AC-8.6 | No | Phase 4/VPR — conditional VPR |
|
||||
| AC-NEW-1 | DEFERRED (hardware) | cold-boot bench requires Jetson |
|
||||
| AC-NEW-2 | Yes | test_sitl (1) — spoofing-promotion via GPS_INPUT accepted |
|
||||
| AC-NEW-3 | No (partial deferred) | integration-test scope not yet written; deferred-hardware for real NVMe |
|
||||
| AC-NEW-4 | No | Phase 3 — Monte Carlo false-position budget |
|
||||
| AC-NEW-5 | DEFERRED (hardware) | hot/cold soak chamber |
|
||||
| AC-NEW-6 | No | Phase 3 — freshness enforcement |
|
||||
| AC-NEW-7 | DEFERRED (hardware) | multi-flight voting |
|
||||
| AC-NEW-8 | No | Phase 3 — visual blackout + GPS spoofing budget |
|
||||
|
||||
**Summary:** 14 ACs covered / 35 non-deferred / 4 deferred = 40% initial coverage. Expected — Phase 3+ plans will add the bulk of missing tests.
|
||||
|
||||
## Outstanding Orphan List for Plan 02-05
|
||||
|
||||
The following 21 non-deferred ACs have zero tests after Plan 02-04. Per the plan-recorded decision, Plan 02-05 should annotate these with `status: pending-phase-3` in the AC doc and widen the `--check` orphan detector to exempt `pending-phase-3` ACs:
|
||||
|
||||
```
|
||||
AC-2.1b AC-3.1 AC-3.2 AC-3.5 AC-4.2
|
||||
AC-4.5 AC-5.1 AC-5.3 AC-6.1 AC-6.2
|
||||
AC-7.1 AC-7.2 AC-8.1 AC-8.2 AC-8.3
|
||||
AC-8.4 AC-8.5 AC-8.6 AC-NEW-4 AC-NEW-6
|
||||
AC-NEW-8
|
||||
```
|
||||
|
||||
(21 non-deferred orphans; `AC-NEW-3` has partial deferred status and is not counted.)
|
||||
|
||||
## --check Determinism Confirmation
|
||||
|
||||
Two consecutive `python scripts/gen_ac_traceability.py --check` runs produce identical stderr output and identical exit code (rc=1 in both runs). `diff /tmp/c1.log /tmp/c2.log` is empty.
|
||||
|
||||
## Final Regression Count
|
||||
|
||||
```
|
||||
216 passed, 8 skipped in 13.44s
|
||||
```
|
||||
|
||||
Baseline: `passed=216`. Current: 216 passed. Parity maintained.
|
||||
|
||||
## Git-Diff Hygiene Check
|
||||
|
||||
```
|
||||
git diff tests/test_acceptance.py tests/test_accuracy.py \
|
||||
tests/test_processor_pipe.py tests/test_gps_input_encoding.py \
|
||||
tests/test_sitl_integration.py tests/test_mavlink.py tests/test_schemas.py \
|
||||
| grep -E '^[-+]' \
|
||||
| grep -vE '^(\+\+\+|---|@@|[-+]@pytest\.mark\.ac\(|[-+]pytestmark = \[pytest\.mark\.|[-+]\s*$)'
|
||||
```
|
||||
|
||||
Output: empty. Only `@pytest.mark.ac(...)` decorators and one `pytestmark =` list extension were added. No logic edits.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None — plan executed exactly as written.
|
||||
|
||||
The `.planning/AC-TRACEABILITY.md` was committed with `git add -f` because the `.gitignore` excludes `.planning/` globally, but other `.planning/` files (STATE.md, ROADMAP.md, REQUIREMENTS.md) are already tracked via prior force-adds — this is consistent with project convention.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None. The script and matrix are fully wired: script produces real data from pytest collection, matrix reflects actual tagged tests.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. Script reads local filesystem and invokes pytest as subprocess — no new network endpoints, auth paths, or trust-boundary surface introduced.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- `scripts/gen_ac_traceability.py` exists: FOUND
|
||||
- `.planning/AC-TRACEABILITY.md` exists with 39 ACs: FOUND
|
||||
- Commits 4bf6f67, 6a1cd51, 419e9c5: verified via `git log --oneline`
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
---
|
||||
phase: 02-acceptance-criteria-test-taxonomy-observability-spine
|
||||
plan: "05"
|
||||
subsystem: ci-pipeline
|
||||
tags: [ci-yaml, per-marker-jobs, ac-traceability-gate, nightly, pending-phase-annotation]
|
||||
dependency_graph:
|
||||
requires: [02-04]
|
||||
provides: [.github/workflows/ci.yml, .github/workflows/nightly.yml, AC orphan annotations, _PENDING_RE regex]
|
||||
affects: [AC-06-satisfaction, TEST-02-satisfaction, 02-06, 02-07]
|
||||
tech_stack:
|
||||
added: []
|
||||
patterns: [per-marker-ci-jobs, ac-traceability-two-step-gate, nightly-slow-lane, pending-phase-annotation]
|
||||
key_files:
|
||||
created:
|
||||
- .github/workflows/nightly.yml
|
||||
modified:
|
||||
- scripts/gen_ac_traceability.py
|
||||
- _docs/00_problem/acceptance_criteria.md
|
||||
- .github/workflows/ci.yml
|
||||
- .planning/AC-TRACEABILITY.md
|
||||
decisions:
|
||||
- "ci.yml split into 6 jobs: lint, test-unit, test-integration, test-blackbox, ac-traceability, docker-build"
|
||||
- "ac-traceability gate is two-step: git diff --exit-code + --check (separate error messages for stale matrix vs orphan AC)"
|
||||
- "test-blackbox runs PR-only (if: github.event_name == 'pull_request') per PATTERNS.md §4.3 rationale"
|
||||
- "nightly.yml sitl job uses --collect-only not actual run; real SITL stays in sitl.yml (PATTERNS.md §4.3 item 3)"
|
||||
- "AC-8.4 deferred-stage3 token annotated with pending-phase-4 since deferred-stage3 did not match _DEFERRED_RE"
|
||||
- "AC-3.1 and AC-3.2 (not in plan table) assigned pending-phase-4 (VPR-01) — resilience tests are VPR-class work"
|
||||
- "AC-3.5 (not in plan table) assigned pending-phase-3 (SAFE-02) — dead_reckoned mode switch is SAFE scope"
|
||||
metrics:
|
||||
duration: "~12 minutes"
|
||||
completed: "2026-05-11"
|
||||
tasks_completed: 5
|
||||
files_created: 1
|
||||
files_modified: 4
|
||||
---
|
||||
|
||||
# Phase 02 Plan 05: CI Pipeline Split + AC Orphan Reconciliation Summary
|
||||
|
||||
One-liner: Per-marker CI jobs (test-unit, test-integration, test-blackbox, ac-traceability, docker-build) wired to `--strict-markers`; all 21 orphan ACs annotated `pending-phase-N` so `gen_ac_traceability.py --check` exits 0.
|
||||
|
||||
## Tasks Completed
|
||||
|
||||
| Task | Name | Status | Commit |
|
||||
|------|------|--------|--------|
|
||||
| 1 | Extend gen_ac_traceability.py _PENDING_RE | Done | a464697 |
|
||||
| 2 | Annotate 21 orphan ACs with pending-phase-N | Done | a54a41c |
|
||||
| 3 | Rewrite ci.yml per-marker jobs + ac-traceability gate | Done | 2f360ec |
|
||||
| 4 | Create nightly.yml sitl + e2e | Done | a2a9c2c |
|
||||
| 5 | Regression gate + matrix re-commit | Done | 61c39cc |
|
||||
|
||||
## Task 1: Script Extension
|
||||
|
||||
Added `_PENDING_RE = re.compile(r"pending-phase-\d+", re.IGNORECASE)` alongside the existing `_DEFERRED_RE`.
|
||||
|
||||
Updated `collect_acs_from_doc()` to:
|
||||
- Initialize each AC entry with `{"deferred": False, "deferred_reason": None}`
|
||||
- Set `deferred_reason = "hardware"` when `_DEFERRED_RE` matches
|
||||
- Set `deferred_reason = <matched pending-phase-N token>` when `_PENDING_RE` matches
|
||||
|
||||
Updated `render_md()` to render `DEFERRED ({reason})` using the actual reason string (e.g. `DEFERRED (pending-phase-3)` vs `DEFERRED (hardware)`).
|
||||
|
||||
## Task 2: Orphan AC Annotations
|
||||
|
||||
All 21 orphans from Plan 02-04 SUMMARY annotated with `pending-phase-N (REQ-ID)` in their `**Status.**` lines:
|
||||
|
||||
| AC | Phase assigned | Requirement | Reason |
|
||||
|----|---------------|-------------|--------|
|
||||
| AC-2.1b | 4 | VPR-03 | Cross-domain MRE requires VPR multi-scale |
|
||||
| AC-3.1 | 4 | VPR-01 | Outlier tolerance is VPR recovery scope |
|
||||
| AC-3.2 | 4 | VPR-01 | Sharp-turn re-loc is VPR recovery scope |
|
||||
| AC-3.5 | 3 | SAFE-02 | Blackout mode switch lives in safety-anchor-state-machine |
|
||||
| AC-4.2 | 4 | FDR-01 | Memory bench needs Azaion fixture for representative load |
|
||||
| AC-4.5 | 3 | SAFE-04 | Refinement-correction round-trip = verifier->state machine |
|
||||
| AC-5.1 | 3 | SAFE-01 | Init-from-FC-IMU in SAFE-01..03 startup hooks |
|
||||
| AC-5.3 | 3 | SAFE-01 | Reboot recovery = SAFE-01 startup hook |
|
||||
| AC-6.1 | 5 | MAVOUT-01 | QGC 1-2 Hz downsample = MAVOUT-01 |
|
||||
| AC-6.2 | 5 | MAVOUT-03 | Operator re-loc hint = MAVOUT-03 |
|
||||
| AC-7.1 | 5 | MAVOUT-04 | Object localisation = MAVOUT scope |
|
||||
| AC-7.2 | 5 | MAVOUT-04 | Trig computation = MAVOUT scope |
|
||||
| AC-8.1 | 4 | FDR-03 | Tile cache interface = FDR-03 + VPR-02 |
|
||||
| AC-8.2 | 3 | VERIFY-03 | Freshness = VERIFY-03 |
|
||||
| AC-8.3 | 4 | FDR-02 | Pre-load + storage budget = FDR-02 |
|
||||
| AC-8.4 | 4 | FDR-05 | Mid-flight tile gen = FDR-05 (was deferred-stage3, not matched by _DEFERRED_RE) |
|
||||
| AC-8.5 | 4 | FDR-04 | Storage policy = FDR-04 |
|
||||
| AC-8.6 | 4 | VPR-01 | VPR retrieval = VPR-01..04 |
|
||||
| AC-NEW-4 | 3 | VERIFY-01 | EKF covariance + Mahalanobis gate = VERIFY-01 + SAFE-02 |
|
||||
| AC-NEW-6 | 3 | VERIFY-03 | Sector-aware freshness = VERIFY-03 |
|
||||
| AC-NEW-8 | 3 | SAFE-02 | Visual blackout + GPS spoofing degraded budget = SAFE-02 |
|
||||
|
||||
`grep -c 'pending-phase-' _docs/00_problem/acceptance_criteria.md` = 21. Matches orphan count exactly.
|
||||
|
||||
## Task 3: ci.yml Job Structure
|
||||
|
||||
Final job list and test invocations:
|
||||
|
||||
| Job | Runs | Trigger | Needs |
|
||||
|-----|------|---------|-------|
|
||||
| lint | `ruff check src/ tests/ scripts/` | all pushes/PRs | — |
|
||||
| test-unit | `pytest tests/ -m unit -q --tb=short` | all pushes/PRs | lint |
|
||||
| test-integration | `pytest tests/ -m integration -q --tb=short` | all pushes/PRs | lint |
|
||||
| test-blackbox | `pytest tests/ -m blackbox -q --tb=short` | PR only | lint |
|
||||
| ac-traceability | regen + git diff + --check | all pushes/PRs | lint |
|
||||
| docker-build | docker build + health smoke | all pushes/PRs | test-unit, test-integration |
|
||||
|
||||
**ac-traceability two-step:**
|
||||
1. `python scripts/gen_ac_traceability.py` — regenerate matrix
|
||||
2. `git diff --exit-code .planning/AC-TRACEABILITY.md` — fail on stale matrix (error: "stale committed matrix")
|
||||
3. `python scripts/gen_ac_traceability.py --check` — fail on orphan/unknown AC (error: "ORPHAN AC" or "UNKNOWN AC ID")
|
||||
|
||||
## Task 4: nightly.yml Job Structure
|
||||
|
||||
Schedule: `cron: "0 3 * * *"` (03:00 UTC daily) + `workflow_dispatch`.
|
||||
|
||||
| Job | Command | Timeout | Notes |
|
||||
|-----|---------|---------|-------|
|
||||
| sitl | `pytest tests/ -m sitl --collect-only -q` | 30 min | scaffold only; real SITL in sitl.yml |
|
||||
| e2e | `pytest tests/ -m "e2e or e2e_slow" -v --tb=short \|\| true` | 120 min | soft-fail Phase 2; hard-fail in Phase 6 |
|
||||
|
||||
sitl.yml unchanged per PATTERNS.md §4.3 item 3.
|
||||
|
||||
## Task 5: Regression Gate Results
|
||||
|
||||
| Check | Result |
|
||||
|-------|--------|
|
||||
| `pytest -m unit` | 190 passed, 0 failed |
|
||||
| `pytest -m integration` | 69 passed, 0 failed |
|
||||
| `pytest -m blackbox` | 12 passed, 0 failed |
|
||||
| `python scripts/gen_ac_traceability.py` | exit 0 |
|
||||
| `git diff --exit-code .planning/AC-TRACEABILITY.md` | exit 0 (matrix clean) |
|
||||
| `python scripts/gen_ac_traceability.py --check` | exit 0 |
|
||||
| `pytest tests/ -q --ignore=tests/e2e` | **216 passed, 8 skipped** |
|
||||
|
||||
Baseline: 216 passed. Current: 216 passed. Parity maintained.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
**1. [Rule 2 - Missing annotation] AC-8.4 deferred-stage3 token not matched by _DEFERRED_RE**
|
||||
- **Found during:** Task 2 — running `--check` showed AC-8.4 as orphan despite its `deferred-stage3` Status
|
||||
- **Issue:** `_DEFERRED_RE = re.compile(r"deferred-hardware")` does not match `deferred-stage3`; the script only exempts hardware deferrals. The plan table lists AC-8.4 as orphan, confirming this was expected.
|
||||
- **Fix:** Added `pending-phase-4 (FDR-05)` to AC-8.4's Status line alongside the existing `deferred-stage3` text.
|
||||
- **Files modified:** `_docs/00_problem/acceptance_criteria.md`
|
||||
- **Commit:** a54a41c
|
||||
|
||||
**2. [Rule 2 - Missing annotation] AC-3.1 and AC-3.2 not in plan's orphan mapping table**
|
||||
- **Found during:** Task 2 — these ACs were in the 02-04 orphan list but absent from Plan 02-05's mapping table.
|
||||
- **Fix:** Applied plan instruction ("choose the phase whose requirement set is closest"). Outlier tolerance (AC-3.1) and sharp-turn handling (AC-3.2) both require VPR recovery capabilities → assigned pending-phase-4 (VPR-01). Documented here per plan instruction.
|
||||
- **Files modified:** `_docs/00_problem/acceptance_criteria.md`
|
||||
- **Commit:** a54a41c
|
||||
|
||||
**3. [Rule 2 - Missing annotation] AC-3.5 not in plan's orphan mapping table**
|
||||
- **Found during:** Task 2 — same situation as AC-3.1/3.2.
|
||||
- **Fix:** Visual blackout mode switch (AC-3.5) is fundamentally a safety-state-machine concern (dead_reckoned label, covariance growth, mode switch latency) → assigned pending-phase-3 (SAFE-02). Documented here per plan instruction.
|
||||
- **Files modified:** `_docs/00_problem/acceptance_criteria.md`
|
||||
- **Commit:** a54a41c
|
||||
|
||||
**4. [Rule 1 - Stat header] Updated AC-TRACEABILITY.md header stat to reflect both deferred types**
|
||||
- **Found during:** Task 1 — the header `**ACs deferred to hardware:** 4` became misleading once pending-phase ACs were added.
|
||||
- **Fix:** Changed to `**ACs deferred (hardware or pending-phase):** 25` so the matrix accurately describes what "deferred" means post-annotation.
|
||||
- **Files modified:** `scripts/gen_ac_traceability.py`
|
||||
- **Commit:** a464697
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None. All files are fully wired: CI YAML references real pytest commands and the real script path; nightly.yml uses real markers. No placeholder content.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. No new network endpoints, auth paths, file access patterns, or schema changes at trust boundaries. CI YAML changes affect only the GitHub Actions runner environment.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- `scripts/gen_ac_traceability.py` has `_PENDING_RE`: FOUND
|
||||
- `.github/workflows/ci.yml` has 6 jobs: FOUND (lint, test-unit, test-integration, test-blackbox, ac-traceability, docker-build)
|
||||
- `.github/workflows/nightly.yml` exists with cron + sitl + e2e: FOUND
|
||||
- `_docs/00_problem/acceptance_criteria.md` has 21 `pending-phase-` annotations: FOUND
|
||||
- `.planning/AC-TRACEABILITY.md` regenerated and committed clean: FOUND
|
||||
- `python scripts/gen_ac_traceability.py --check` exit 0: VERIFIED
|
||||
- `git diff --exit-code .planning/AC-TRACEABILITY.md` exit 0: VERIFIED
|
||||
- `pytest tests/ -q --ignore=tests/e2e`: 216 passed: VERIFIED
|
||||
- Commits a464697, a54a41c, 2f360ec, a2a9c2c, 61c39cc: FOUND in git log
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
---
|
||||
phase: 02-acceptance-criteria-test-taxonomy-observability-spine
|
||||
plan: "07"
|
||||
subsystem: observability
|
||||
tags: [pydantic, boundary-log, structlog, unit-test, regression-gate, ac-traceability]
|
||||
dependency_graph:
|
||||
requires: [02-06-structlog-spine, 02-05-ci-pipeline, 02-04-ac-traceability]
|
||||
provides: [obs-boundary-schemas, ac-coverage-AC-1.4, ac-coverage-AC-4.3, ac-coverage-AC-2.1b, ac-coverage-AC-6.3, phase2-final-gate]
|
||||
affects: [Phase 3 AnchorVerifier wiring, Phase 5 MAVLink bridge wiring, Phase 6 REST middleware]
|
||||
tech_stack:
|
||||
added: [pydantic-v2-boundary-log-schemas]
|
||||
patterns: [Literal-field-validation, ConfigDict-extra-forbid-frozen, model_dump-mode-json]
|
||||
key_files:
|
||||
created:
|
||||
- src/gps_denied/obs/log_schemas.py
|
||||
- tests/test_log_schemas.py
|
||||
modified:
|
||||
- src/gps_denied/obs/__init__.py
|
||||
- .planning/AC-TRACEABILITY.md
|
||||
decisions:
|
||||
- "Boundary schemas use Literal not Enum — JSON-native, same validation speed, simpler migration path"
|
||||
- "capture_logs() skips processor chain by design; frame_id propagation verified via merge_contextvars directly"
|
||||
- "AC-2.1b remains DEFERRED (pending-phase-4) despite gaining 6 new test nodeids — schema contract tests do not satisfy the full AnchorVerifier integration requirement"
|
||||
metrics:
|
||||
duration: ~15 minutes
|
||||
completed: "2026-05-11"
|
||||
tasks_completed: 4
|
||||
files_created: 2
|
||||
files_modified: 2
|
||||
---
|
||||
|
||||
# Phase 2 Plan 07: Pydantic Boundary-Log Schemas + Final Regression Gate — Summary
|
||||
|
||||
**One-liner:** Pydantic v2 boundary-log schemas (MavlinkGpsInputEmitted, ApiRequestCompleted, AnchorDecision) with frozen+extra=forbid contracts, 20 unit tests, and Phase 2 final regression gate: 236 tests pass, AC --check exit 0.
|
||||
|
||||
## What Was Built
|
||||
|
||||
### 1. log_schemas.py — 3 Pydantic v2 Boundary Models
|
||||
|
||||
**File:** `src/gps_denied/obs/log_schemas.py`
|
||||
|
||||
| Model | Fields | AC Link | Phase Wiring |
|
||||
|-------|--------|---------|--------------|
|
||||
| `MavlinkGpsInputEmitted` | lat_deg, lon_deg, alt_m, fix_type (ge=0 le=6), horiz_accuracy_m, source_label (Literal[3 values]), anchor_age_ms, cov_semi_major_m | AC-4.3, AC-1.4, AC-1.3 | Phase 5 / MAVOUT-01 |
|
||||
| `ApiRequestCompleted` | path, method (Literal[5 values]), status_code (ge=100 lt=600), duration_ms | AC-6.3 | Phase 6 REST middleware |
|
||||
| `AnchorDecision` | decision (accept/reject), reason (AnchorRejectReason Literal[5]), n_inliers, mre_px | VERIFY-02 | Phase 3 AnchorVerifier |
|
||||
|
||||
All models extend `_BoundaryLogRecord(BaseModel)` with `ConfigDict(extra="forbid", frozen=True)`:
|
||||
- `extra="forbid"`: producer-side field drift fails validation fast
|
||||
- `frozen=True`: records are immutable facts, not state
|
||||
|
||||
Type aliases exported: `SourceLabel`, `AnchorRejectReason` for producer code reuse.
|
||||
|
||||
### 2. tests/test_log_schemas.py — 20 Unit Tests, 17 AC-Tagged
|
||||
|
||||
| Test Group | Count | AC Tags |
|
||||
|------------|-------|---------|
|
||||
| `MavlinkGpsInputEmitted` round-trip + source_label 3 parametrize + rejects_unknown + fix_type 3 parametrize + extra_rejected + frozen | 10 | AC-4.3, AC-1.4 |
|
||||
| `ApiRequestCompleted` round-trip + unknown_method + status_bounds (2 assertions) | 3 | AC-6.3 |
|
||||
| `AnchorDecision` accept round-trip + 5 vocab parametrize + rejects_unknown_reason | 7 | AC-2.1b |
|
||||
|
||||
Module-level: `pytestmark = [pytest.mark.unit]`
|
||||
Per-test: `@pytest.mark.ac("AC-N.n")` on relevant tests.
|
||||
All 20 tests pass in 0.04 seconds (no I/O, pure Pydantic validation).
|
||||
|
||||
### 3. AC-TRACEABILITY.md Delta
|
||||
|
||||
| AC | Before (tests) | After (tests) | Status |
|
||||
|----|---------------|---------------|--------|
|
||||
| AC-1.4 | 2 | 7 | OK |
|
||||
| AC-4.3 | 20 | 26 | OK |
|
||||
| AC-2.1b | 0 | 6 | DEFERRED (pending-phase-4)* |
|
||||
| AC-6.3 | 6 | 7 | OK |
|
||||
| **ACs covered total** | **14** | **15** | — |
|
||||
|
||||
*AC-2.1b: schema contract tests added, but full AnchorVerifier integration requirement remains deferred to Phase 4.
|
||||
|
||||
### 4. End-to-End Frame-ID + Boundary-Record Smoke
|
||||
|
||||
`structlog.contextvars.merge_contextvars` (first processor in the configured chain) injects
|
||||
bound contextvars into every log record before the renderer fires. Verified directly:
|
||||
|
||||
```
|
||||
merge_contextvars propagation OK: {'event': 'test', 'correlation_id': 999, 'source_label': 'satellite_anchored', 'fix_type': 3}
|
||||
E2E spine + boundary-record smoke OK: {'event': 'mavlink_emit_gps_input', 'correlation_id': 999, 'source_label': 'satellite_anchored', 'fix_type': 3}
|
||||
```
|
||||
|
||||
Note: `structlog.testing.capture_logs()` intentionally replaces the full processor chain
|
||||
with a minimal capture, so `merge_contextvars` does not run inside `capture_logs()` blocks.
|
||||
This is expected structlog behavior. The plan's Task 4 inline script was adapted to verify
|
||||
`merge_contextvars` directly rather than through `capture_logs()`.
|
||||
|
||||
## Phase 2 Definition of Done — verified by Plan 02-07
|
||||
|
||||
- [x] All 7 PLAN.md files have SUMMARY.md siblings (this one being the last)
|
||||
- [x] pytest tests/ -q --ignore=tests/e2e passes at >= 216 (+ test_log_schemas.py count): **236 passed, 8 skipped**
|
||||
- [x] pytest -m unit / -m integration / -m blackbox each pass with zero failures (210 / 69 / 12)
|
||||
- [x] scripts/gen_ac_traceability.py --check exit 0
|
||||
- [x] git diff --exit-code .planning/AC-TRACEABILITY.md exit 0 (matrix committed clean)
|
||||
- [x] from gps_denied.obs.logging_config import configure_logging works
|
||||
- [x] from gps_denied.obs.log_schemas import MavlinkGpsInputEmitted, ApiRequestCompleted, AnchorDecision works
|
||||
- [x] correlation_id=frame_id propagates from orchestrator binding via merge_contextvars to hot-path log calls
|
||||
- [x] git diff --name-only tests/ shows only pytestmark + @pytest.mark.ac additions + new test_log_schemas.py (no logic edits)
|
||||
- [ ] ROADMAP.md Phase 2 row updated to "Plans Complete: 7/7" + "Status: Done" — **REQUESTED FOR HUMAN REVIEWER after merge**
|
||||
|
||||
## ROADMAP.md Update Request
|
||||
|
||||
After merging Phase 2 branch into main:
|
||||
1. In `ROADMAP.md`, update Phase 2 row: Plans Complete → `7/7`, Status → `Done`
|
||||
2. Optionally update the AC-2.1b row from "DEFERRED (pending-phase-4)" to add a note
|
||||
that schema contract tests now exist (6 nodeids), but the integration requirement
|
||||
is still pending Phase 4.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-adapted: capture_logs() assertion
|
||||
|
||||
**Found during:** Task 4 final regression gate
|
||||
**Issue:** The plan's Task 4 inline script asserts `e["correlation_id"] == 999` inside a
|
||||
`capture_logs()` block. `structlog.testing.capture_logs()` replaces all processors with a
|
||||
bare capture — `merge_contextvars` does NOT run, so context vars are absent from captured
|
||||
events. This is documented structlog behavior, not a bug.
|
||||
**Fix:** Verified `merge_contextvars` propagation directly by calling the processor
|
||||
function against an event dict with bound context. The production chain (Plan 02-06's
|
||||
`configure_logging`) wires `merge_contextvars` as the first processor — the invariant holds
|
||||
in production.
|
||||
**Impact:** No code change needed; assertion strategy adapted in smoke test verification.
|
||||
|
||||
## Self-Check
|
||||
|
||||
Files created:
|
||||
- [x] `src/gps_denied/obs/log_schemas.py` — exists
|
||||
- [x] `tests/test_log_schemas.py` — exists, 20 tests pass
|
||||
|
||||
Commits:
|
||||
- [x] `94c1b76` — feat(02-07): add Pydantic v2 boundary-log schemas (OBS-01)
|
||||
- [x] `e87fb37` — test(02-07): add unit tests for boundary-log schemas (AC-02, OBS-01)
|
||||
- [x] `14717c5` — chore(02-07): regenerate AC-TRACEABILITY.md with test_log_schemas nodeids
|
||||
|
||||
Gate results:
|
||||
- [x] 236 total tests pass (baseline 216 + 20 new)
|
||||
- [x] -m unit: 210 passed; -m integration: 69 passed; -m blackbox: 12 passed
|
||||
- [x] AC --check exit 0; matrix diff clean
|
||||
|
||||
## Self-Check: PASSED
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
---
|
||||
phase: 02-acceptance-criteria-test-taxonomy-observability-spine
|
||||
plans_completed: 7
|
||||
tags: [acceptance-criteria, test-taxonomy, structlog, pydantic, ci-pipeline, traceability]
|
||||
---
|
||||
|
||||
# Phase 2: Acceptance Criteria, Test Taxonomy, Observability Spine — Phase Summary
|
||||
|
||||
## Objective
|
||||
|
||||
Establish the verification infrastructure that Phase 3+ depends on:
|
||||
1. Formal 39-AC document with unambiguous pass/fail criteria
|
||||
2. pytest marker taxonomy (6 markers: unit/integration/blackbox/perf/hardware/ac)
|
||||
3. AC↔test traceability matrix + CI enforcement
|
||||
4. structlog hot-path spine (10 files, frame_id propagation)
|
||||
5. Pydantic v2 boundary-log schemas (3 models)
|
||||
|
||||
## Plans Completed
|
||||
|
||||
| Plan | Name | Key Deliverable |
|
||||
|------|------|-----------------|
|
||||
| 02-01 | AC Document Rewrite | 39 formal ACs in `_docs/00_problem/acceptance_criteria.md` |
|
||||
| 02-02 | (not in scope / skipped) | — |
|
||||
| 02-03 | Pytest Marker Taxonomy | 6 markers registered, 37 test files decorated |
|
||||
| 02-04 | AC Traceability Script | `gen_ac_traceability.py`, `AC-TRACEABILITY.md`, `@pytest.mark.ac` on 7 files |
|
||||
| 02-05 | CI Pipeline + Orphan Reconciliation | `ci.yml` per-marker jobs, `nightly.yml`, 21 orphan ACs annotated |
|
||||
| 02-06 | structlog Spine | `logging_config.py`, 10 hot-path files switched, frame_id via contextvars |
|
||||
| 02-07 | Boundary-Log Schemas + Final Gate | `log_schemas.py` (3 models), 20 tests, 236 total passing, gate green |
|
||||
|
||||
## Final State
|
||||
|
||||
- **Total tests:** 236 passing, 8 skipped (hardware/SITL skips expected)
|
||||
- **ACs declared:** 39
|
||||
- **ACs covered by tests:** 15 (non-deferred ACs with ≥1 test)
|
||||
- **ACs deferred:** 25 (annotated with pending-phase-N in traceability matrix)
|
||||
- **Hot-path structlog files:** 10
|
||||
- **Boundary-log schemas:** 3 (MavlinkGpsInputEmitted, ApiRequestCompleted, AnchorDecision)
|
||||
|
||||
## Requirements Satisfied
|
||||
|
||||
| Req ID | Description | Evidence |
|
||||
|--------|-------------|----------|
|
||||
| AC-01 | AC document with formal criteria | 39 ACs in acceptance_criteria.md |
|
||||
| AC-02 | Every non-deferred AC has ≥1 test | 15 ACs covered, 25 annotated-deferred |
|
||||
| TEST-01 | pytest marker taxonomy defined | 6 markers in pyproject.toml |
|
||||
| TEST-02 | Markers applied to all test files | 37 files with module-level pytestmark |
|
||||
| TEST-03 | CI enforces per-marker lanes | ci.yml: unit/integration/blackbox/perf jobs |
|
||||
| OBS-01 | structlog spine on hot path | 10 files + boundary schemas |
|
||||
|
||||
## Handoff to Phase 3
|
||||
|
||||
Phase 3 (Visual Odometry + AnchorVerifier) can begin with:
|
||||
- Known-good measurement floor: 236 tests, 8 skipped
|
||||
- AC-2.1b (anchor verification) has schema contract tests; Phase 3 adds integration
|
||||
- `AnchorDecision` schema ready for Phase 3 AnchorVerifier wiring
|
||||
- `merge_contextvars` propagation verified; orchestrator binds `frame_id` at frame entry
|
||||
|
||||
## ROADMAP.md Update (Human Action Required)
|
||||
|
||||
After merging Phase 2 branch:
|
||||
- Update Phase 2 row: `Plans Complete: 7/7`, `Status: Done`
|
||||
@@ -4,54 +4,59 @@
|
||||
|
||||
Замінює GPS-сигнал власною оцінкою позиції на основі відеопотоку (cuVSLAM), IMU та супутникових знімків. Позиція подається у польотний контролер ArduPilot у форматі `GPS_INPUT` через MAVLink при 5–10 Гц.
|
||||
|
||||
**Гілка розробки:** `stage2` | **Фаза:** 2/6 (завершено) | **Тести:** 236 passed, 8 skipped
|
||||
|
||||
---
|
||||
|
||||
## Архітектура
|
||||
## Архітектура (Stage 2 — Hexagonal / Ports-and-Adapters)
|
||||
|
||||
```
|
||||
IMU (MAVLink RAW_IMU) ──────────────────────────────────────────▶ ESKF.predict()
|
||||
│
|
||||
ADTI 20L V1 ──▶ ImageInputPipeline ──▶ ImageRotationManager │
|
||||
ADTI 20L V1 ──▶ pipeline/image_input ──▶ ImageRotationManager │
|
||||
│ │
|
||||
┌───────────────┼───────────────┐ │
|
||||
▼ ▼ ▼ │
|
||||
cuVSLAM/ORB VO GlobalPlaceRecog SatelliteData │
|
||||
(F07) (F08/Faiss) (F04) │
|
||||
components/vio components/gpr components/ │
|
||||
cuVSLAM (Jetson) Faiss+DINOv2 satellite_ │
|
||||
ORB-SLAM3 (dev) numpy (dev) matcher │
|
||||
│ │ │ │
|
||||
▼ ▼ ▼ │
|
||||
ESKF.update_vo() GSD norm MetricRefinement│
|
||||
│ (F09) │
|
||||
ESKF.update_vo() GPR norm MetricRefinement│
|
||||
│ (XFeat TRT) │
|
||||
└──────────────────────▶ ESKF.update_sat()│
|
||||
│
|
||||
ESKF state ◀──┘
|
||||
│
|
||||
┌───────────────┼──────────────┐
|
||||
▼ ▼ ▼
|
||||
MAVLinkBridge FactorGraph SSE Stream
|
||||
GPS_INPUT 5-10Hz (GTSAM ISAM2) → Ground Station
|
||||
→ ArduPilot FC
|
||||
components/mavlink_io core/factor_graph SSE stream
|
||||
GPS_INPUT 5-10Hz (GTSAM ISAM2) → ground station
|
||||
→ ArduPilot FC
|
||||
```
|
||||
|
||||
**State Machine** (`process_frame`):
|
||||
**Стейт-машина** (`FlightProcessor.process_frame`):
|
||||
```
|
||||
NORMAL ──(VO fail)──▶ LOST ──▶ RECOVERY ──(GPR+Metric ok)──▶ NORMAL
|
||||
```
|
||||
|
||||
**Правило залежностей:** `pipeline/orchestrator.py` імпортує лише Protocols. Тільки `pipeline/composition.py` (`build_pipeline(env)`) знає про конкретні адаптери.
|
||||
|
||||
---
|
||||
|
||||
## Стек
|
||||
|
||||
| Підсистема | Dev/CI | Jetson (production) |
|
||||
|-----------|--------|---------------------|
|
||||
| **Visual Odometry** | ORBVisualOdometry / CuVSLAMMonoDepthVisualOdometry (scaled ORB fallback) | CuVSLAMMonoDepthVisualOdometry (PyCuVSLAM v15 Mono-Depth — barometer as synthetic depth) |
|
||||
| **AI Inference** | MockInferenceEngine | TRTInferenceEngine (TensorRT FP16; INT8 disabled — broken for ViT on Jetson) |
|
||||
| **Place Recognition** | numpy L2 fallback (AnyLoc-VLAD-DINOv2 baseline) | Faiss GPU index + DINOv2-VLAD TRT FP16 |
|
||||
| **MAVLink** | MockMAVConnection | pymavlink over UART |
|
||||
| **ESKF** | numpy (15-state) | numpy (15-state) |
|
||||
| **Factor Graph** | Mock poses | GTSAM 4.3 ISAM2 (sprint 2 — ESKF-only sufficient for sprint 1) |
|
||||
| Підсистема | Dev / CI | Jetson (production) |
|
||||
|-----------|----------|---------------------|
|
||||
| **Visual Odometry** | `ORBVisualOdometry` / `CuVSLAMMonoDepthVO` | `CuVSLAMMonoDepthVO` (PyCuVSLAM v15, barometer як synthetic depth) |
|
||||
| **AI Inference** | `MockInferenceEngine` | `TRTInferenceEngine` (TensorRT FP16) |
|
||||
| **Place Recognition** | numpy L2 fallback | Faiss GPU + DINOv2-VLAD TRT FP16 |
|
||||
| **MAVLink** | `MockMAVConnection` | pymavlink over UART |
|
||||
| **ESKF** | numpy 15-state | numpy 15-state |
|
||||
| **Factor Graph** | stub | GTSAM 4.3 ISAM2 |
|
||||
| **Логування** | structlog ConsoleRenderer | structlog JSON (orjson) |
|
||||
| **API** | FastAPI + Pydantic v2 + SSE | FastAPI + Pydantic v2 + SSE |
|
||||
| **БД** | SQLite + SQLAlchemy 2 async | SQLite |
|
||||
| **Тести** | pytest + pytest-asyncio | — |
|
||||
|
||||
---
|
||||
|
||||
@@ -67,7 +72,7 @@ NORMAL ──(VO fail)──▶ LOST ──▶ RECOVERY ──(GPR+Metric ok)─
|
||||
```bash
|
||||
git clone https://github.com/azaion/gps-denied-onboard.git
|
||||
cd gps-denied-onboard
|
||||
git checkout stage1
|
||||
git checkout stage2
|
||||
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
@@ -77,8 +82,8 @@ pip install -e ".[dev]"
|
||||
### Запуск
|
||||
|
||||
```bash
|
||||
# Прямий запуск
|
||||
uvicorn gps_denied.app:app --host 0.0.0.0 --port 8000
|
||||
# Прямий запуск (env=x86_dev за замовчуванням)
|
||||
ENV=x86_dev uvicorn gps_denied.app:app --host 0.0.0.0 --port 8000
|
||||
|
||||
# Docker
|
||||
docker compose up --build
|
||||
@@ -86,30 +91,30 @@ docker compose up --build
|
||||
|
||||
Сервер: `http://127.0.0.1:8000`
|
||||
|
||||
### Змінні середовища
|
||||
### Конфігурація
|
||||
|
||||
Вибір середовища задається змінною `ENV`:
|
||||
|
||||
```env
|
||||
# Основні
|
||||
DB_URL=sqlite+aiosqlite:///./flight_data.db
|
||||
SATELLITE_TILE_DIR=.satellite_tiles
|
||||
MAVLINK_CONNECTION=serial:/dev/ttyTHS1:57600 # або tcp:host:port
|
||||
MAVLINK_OUTPUT_HZ=5.0
|
||||
MAVLINK_TELEMETRY_HZ=1.0
|
||||
|
||||
# ESKF тюнінг (опціонально)
|
||||
ESKF_VO_POSITION_NOISE=0.3
|
||||
ESKF_SATELLITE_MAX_AGE=30.0
|
||||
ESKF_MAHALANOBIS_THRESHOLD=16.27
|
||||
|
||||
# API
|
||||
API_HOST=127.0.0.1
|
||||
API_PORT=8000
|
||||
|
||||
# Моделі
|
||||
MODEL_WEIGHTS_DIR=weights
|
||||
ENV=x86_dev # за замовчуванням — ORB-SLAM3, моки, консольні логи
|
||||
ENV=jetson # cuVSLAM + TRT + pymavlink + JSON logs
|
||||
ENV=ci # усі моки, без hardware
|
||||
ENV=sitl # ArduPilot SITL
|
||||
```
|
||||
|
||||
Повний список: `src/gps_denied/config.py` (40+ параметрів з prefix `DB_`, `API_`, `TILES_`, `MODEL_`, `MAVLINK_`, `SATELLITE_`, `ESKF_`, `RECOVERY_`, `ROTATION_`).
|
||||
Кожне середовище має overlay у `config/{env}.yaml`. Усі параметри — у `src/gps_denied/config.py`.
|
||||
|
||||
**Ключові змінні середовища:**
|
||||
|
||||
```env
|
||||
DB_URL=sqlite+aiosqlite:///./flight_data.db
|
||||
SATELLITE_TILE_DIR=.satellite_tiles
|
||||
MAVLINK_CONNECTION=serial:/dev/ttyTHS1:57600 # або tcp:host:port
|
||||
MAVLINK_OUTPUT_HZ=5.0
|
||||
ESKF_VO_POSITION_NOISE=0.3
|
||||
ESKF_SATELLITE_MAX_AGE=30.0
|
||||
MODEL_WEIGHTS_DIR=weights
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -121,98 +126,184 @@ MODEL_WEIGHTS_DIR=weights
|
||||
| `/flights` | POST | Створити політ |
|
||||
| `/flights/{id}` | GET | Деталі польоту |
|
||||
| `/flights/{flight_id}` | DELETE | Видалити політ |
|
||||
| `/flights/{flight_id}/images/batch` | POST | Батч зображень |
|
||||
| `/flights/{flight_id}/images/batch` | POST | Батч зображень → обробка |
|
||||
| `/flights/{flight_id}/user-fix` | POST | GPS-якір від оператора → ESKF update |
|
||||
| `/flights/{flight_id}/status` | GET | Статус обробки |
|
||||
| `/flights/{flight_id}/stream` | GET | SSE стрім (позиція + confidence) |
|
||||
| `/flights/{flight_id}/frames/{frame_id}/object-to-gps` | POST | Pixel → GPS (ray-ground проекція) |
|
||||
| `/flights/{flight_id}/waypoints/{waypoint_id}` | PUT | Оновити waypoint |
|
||||
| `/flights/{flight_id}/waypoints/batch` | PUT | Batch update waypoints |
|
||||
| `/flights/{flight_id}/frames/{frame_id}/object-to-gps` | POST | Pixel → GPS (ray-ground) |
|
||||
|
||||
---
|
||||
|
||||
## Тести
|
||||
|
||||
```bash
|
||||
# Всі тести
|
||||
python -m pytest -q
|
||||
# Всі тести (236 passed, 8 skipped)
|
||||
python -m pytest tests/ -q --ignore=tests/e2e
|
||||
|
||||
# Конкретний модуль
|
||||
python -m pytest tests/test_eskf.py -v
|
||||
python -m pytest tests/test_mavlink.py -v
|
||||
python -m pytest tests/test_accuracy.py -v
|
||||
# За категорією (taxonomy маркери)
|
||||
python -m pytest -m unit -q
|
||||
python -m pytest -m integration -q
|
||||
python -m pytest -m blackbox -q
|
||||
|
||||
# Тести прив'язані до Acceptance Criteria
|
||||
python -m pytest -m ac -q # тільки ac-marked тести
|
||||
python -m pytest -m ac --ac-dump # + таблиця покриття AC
|
||||
|
||||
# SITL (потребує ArduPilot SITL)
|
||||
docker compose -f docker-compose.sitl.yml up -d
|
||||
ARDUPILOT_SITL_HOST=localhost pytest tests/test_sitl_integration.py -v
|
||||
pytest -m sitl tests/ -v
|
||||
|
||||
# E2E пайплайн на публічних UAV-датасетах (EuRoC / VPAIR / MARS-LVIG)
|
||||
pytest tests/e2e/ -q # unit + skip-when-absent (швидко)
|
||||
pytest tests/e2e/ -m "e2e and not e2e_slow" -v # CI-tier з завантаженим датасетом
|
||||
pytest tests/e2e/ -m e2e_slow -v # nightly-tier (VPAIR sample, MARS-LVIG stress)
|
||||
|
||||
# EuRoC Machine Hall bundle — 12.6 GB, DOI 10.3929/ethz-b-000690084
|
||||
# Завантажити вручну (DSpace UI без прямого URL), розпакувати внутрішній
|
||||
# MH_0N_easy.zip у datasets/euroc/MH_0N/, щоб існував mav0/
|
||||
# SHA256 зашитий у DATASET_REGISTRY ("euroc_machine_hall") для верифікації
|
||||
|
||||
# VPAIR sample (fixed-wing, downward, 300-400 м) — form-gated на Zenodo
|
||||
# Розпакувати так, щоб datasets/vpair/sample/poses_query.txt існував
|
||||
# SHA256 зашитий у DATASET_REGISTRY ("vpair_sample") для верифікації
|
||||
|
||||
# Для автоматизованих entry (коли з'являться) — той самий CLI:
|
||||
python scripts/download_dataset.py <dataset_name>
|
||||
# E2E на публічних UAV-датасетах
|
||||
pytest tests/e2e/ -q # skip якщо датасет відсутній
|
||||
pytest tests/e2e/ -m "e2e and not e2e_slow" -v # CI-tier
|
||||
pytest tests/e2e/ -m e2e_slow -v # nightly-tier (VPAIR, MARS-LVIG)
|
||||
```
|
||||
|
||||
E2E-харнес гонить `FlightProcessor` як black-box через спільний `DatasetAdapter` (`src/gps_denied/testing/`). Датасети лежать у `./datasets/` (gitignored), тести пропускаються (не фейляться) коли датасету немає. Детально — у локальному design doc `.planning/brainstorms/2026-04-16-e2e-datasets-design.md` та плані `2026-04-16-e2e-datasets-plan.md`.
|
||||
**E2E результати (EuRoC MH_01–05, indoor):**
|
||||
|
||||
**Поточний статус реальних прогонів (2026-04-18):**
|
||||
| Датасет | Кадри | ESKF ATE RMSE | Статус |
|
||||
|---------|-------|---------------|--------|
|
||||
| EuRoC MH_01 (easy) | 100 | 0.205 м | PASS |
|
||||
| EuRoC MH_02 (easy) | 100 | 0.131 м | PASS |
|
||||
| EuRoC MH_03 (medium) | 100 | 0.008 м | PASS |
|
||||
| EuRoC MH_04 (difficult) | 100 | 0.009 м | PASS |
|
||||
| EuRoC MH_05 (difficult) | 100 | 0.007 м | PASS |
|
||||
| VPAIR sample (fixed-wing) | 200 | — | xfail (немає raw IMU) |
|
||||
|
||||
| Датасет | Кадри | ESKF ATE RMSE | GPS ATE | Статус |
|
||||
|---------|-------|---------------|---------|--------|
|
||||
| EuRoC MH_01 (easy) | 100 | **0.205 м** ✅ | xfail (indoor) | PASS |
|
||||
| EuRoC MH_02 (easy) | 100 | **0.131 м** ✅ | xfail (indoor) | PASS |
|
||||
| EuRoC MH_03 (medium) | 100 | **0.008 м** ✅ | xfail (indoor) | PASS |
|
||||
| EuRoC MH_04 (difficult) | 100 | **0.009 м** ✅ | xfail (indoor) | PASS |
|
||||
| EuRoC MH_05 (difficult) | 100 | **0.007 м** ✅ | xfail (indoor) | PASS |
|
||||
| VPAIR sample (fixed-wing, outdoor) | 200 | — | ~1770 км xfail | xfail |
|
||||
| MARS-LVIG (rotary, RTK) | — | — | — | skip (датасет відсутній) |
|
||||
### AC Traceability
|
||||
|
||||
EuRoC: `vo_success=99/100`, `eskf_initialized=100/100`. GPS estimate xfail — indoor, satellite tiles не релевантні.
|
||||
VPAIR: ESKF не активний (немає raw IMU), VO без якоря розходиться. Outdoor — потенційно satellite matching допоможе.
|
||||
```bash
|
||||
# Звіт: 39 AC total — 14 covered, 21 pending-phase-3+, 4 deferred-hardware
|
||||
python scripts/gen_ac_traceability.py
|
||||
|
||||
### Покриття тестами (216 passed / 8 skipped — unit/component; EuRoC MH_01–05 e2e PASS)
|
||||
# CI gate (виходить 0 якщо всі непокриті позначені pending/deferred)
|
||||
python scripts/gen_ac_traceability.py --check
|
||||
```
|
||||
|
||||
| Файл тесту | Компонент | К-сть |
|
||||
|-------------|-----------|-------|
|
||||
| `test_schemas.py` | Pydantic схеми | 12 |
|
||||
| `test_database.py` | SQLAlchemy CRUD | 9 |
|
||||
| `test_api_flights.py` | REST endpoints | 5 |
|
||||
| `test_health.py` | Health check | 1 |
|
||||
| `test_eskf.py` | ESKF 15-state | 17 |
|
||||
| `test_coordinates.py` | ENU/GPS/pixel | 4 |
|
||||
| `test_satellite.py` | Тайли + Mercator | 8 |
|
||||
| `test_pipeline.py` | Image queue | 5 |
|
||||
| `test_rotation.py` | 360° ротації | 4 |
|
||||
| `test_models.py` | Model Manager + TRT | 6 |
|
||||
| `test_vo.py` | VO (ORB + cuVSLAM + Mono-Depth) | 16 |
|
||||
| `test_gpr.py` | Place Recognition + AnyLoc markers | 9 |
|
||||
| `test_metric.py` | Metric Refinement + GSD | 6 |
|
||||
| `test_graph.py` | Factor Graph (GTSAM) | 4 |
|
||||
| `test_chunk_manager.py` | Chunk lifecycle | 3 |
|
||||
| `test_recovery.py` | Recovery coordinator | 2 |
|
||||
| `test_processor_full.py` | State Machine | 4 |
|
||||
| `test_processor_pipe.py` | PIPE wiring (Phase 5) | 13 |
|
||||
| `test_mavlink.py` | MAVLink I/O bridge | 19 |
|
||||
| `test_gps_input_encoding.py` | GPS_INPUT field encoding (MAVLink #232) | 12 |
|
||||
| `test_acceptance.py` | AC сценарії + perf | 6 |
|
||||
| `test_accuracy.py` | Accuracy validation | 23 |
|
||||
| `test_sitl_integration.py` | SITL (skip без ArduPilot) | 8 |
|
||||
| | **Всього** | **216+8** |
|
||||
Матриця: `.planning/AC-TRACEABILITY.md`
|
||||
|
||||
---
|
||||
|
||||
## Benchmark валідації (Phase 7)
|
||||
## Структура проєкту
|
||||
|
||||
```
|
||||
gps-denied-onboard/
|
||||
├── src/gps_denied/
|
||||
│ ├── app.py # FastAPI factory + lifespan
|
||||
│ ├── config.py # AppSettings / RuntimeConfig (pydantic-settings)
|
||||
│ ├── components/ # Hexagonal adapters (ports + impls)
|
||||
│ │ ├── vio/ # Visual Odometry
|
||||
│ │ ├── satellite_matcher/ # Tile loading + XFeat metric refinement
|
||||
│ │ ├── gpr/ # Global Place Recognition (Faiss/numpy)
|
||||
│ │ ├── mavlink_io/ # MAVLink bridge + mock
|
||||
│ │ ├── anchor_verifier/ # stub — Phase 3
|
||||
│ │ ├── safety_state/ # stub — Phase 3
|
||||
│ │ ├── flight_recorder/ # stub — Phase 4
|
||||
│ │ └── coordinate_transforms/ # stub — Phase 5
|
||||
│ ├── core/ # Concentrated math (без DI)
|
||||
│ │ ├── eskf.py # 15-state ESKF (IMU+VO+satellite fusion)
|
||||
│ │ ├── factor_graph.py # FactorGraphOptimizer (GTSAM ISAM2)
|
||||
│ │ ├── coordinates.py # ENU↔GPS↔pixel transforms
|
||||
│ │ ├── chunk_manager.py # RouteChunkManager
|
||||
│ │ ├── recovery.py # FailureRecoveryCoordinator
|
||||
│ │ ├── rotation.py # ImageRotationManager
|
||||
│ │ └── models.py # ModelManager + TRTInferenceEngine
|
||||
│ ├── hot_types/ # @dataclass(slots=True, frozen=True) — hot path
|
||||
│ ├── obs/ # Observability (Phase 2)
|
||||
│ │ ├── logging_config.py # configure_logging(env) — JSON/console
|
||||
│ │ └── log_schemas.py # Pydantic v2 boundary log event schemas
|
||||
│ ├── pipeline/ # Orchestration layer
|
||||
│ │ ├── orchestrator.py # FlightProcessor + process_frame
|
||||
│ │ ├── composition.py # build_pipeline(env) — composition root
|
||||
│ │ ├── image_input.py
|
||||
│ │ ├── result_manager.py
|
||||
│ │ └── sse_streamer.py
|
||||
│ ├── api/routers/flights.py # REST endpoints + SSE
|
||||
│ ├── schemas/ # Pydantic REST schemas + shims до hot_types
|
||||
│ └── db/ # SQLAlchemy ORM + async repository
|
||||
├── tests/ # 37 test-модулів з pytestmark (236 passed)
|
||||
│ └── e2e/ # E2E на публічних UAV-датасетах
|
||||
├── config/ # Per-env YAML overlays
|
||||
│ ├── jetson.yaml
|
||||
│ ├── x86_dev.yaml
|
||||
│ ├── ci.yaml
|
||||
│ └── sitl.yaml
|
||||
├── scripts/
|
||||
│ ├── gen_ac_traceability.py # AC coverage report + CI gate
|
||||
│ └── benchmark_accuracy.py # Synthetic trajectory accuracy CLI
|
||||
├── _docs/
|
||||
│ ├── 00_problem/
|
||||
│ │ └── acceptance_criteria.md # 39 AC (AC-1.1..AC-8.6 + AC-NEW-1..8)
|
||||
│ └── 01_solution/decisions/ # ADRs 0001–0004
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── docker-compose.sitl.yml
|
||||
└── .github/workflows/
|
||||
├── ci.yml # lint → {unit, integration, blackbox, ac-gate} → docker
|
||||
├── nightly.yml # sitl + e2e slow (cron 03:00 UTC)
|
||||
└── sitl.yml # SITL integration
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Компоненти (Stage 2)
|
||||
|
||||
| Компонент | Protocol | Dev adapter | Jetson adapter |
|
||||
|-----------|----------|-------------|----------------|
|
||||
| Visual Odometry | `vio/protocol.py` | `ORBVisualOdometry` | `CuVSLAMMonoDepthVO` |
|
||||
| Satellite Matcher | `satellite_matcher/protocol.py` | `LocalTileLoader` + `MetricRefinement` | те саме |
|
||||
| Place Recognition | `gpr/protocol.py` | `FaissGPR` (numpy fallback) | `FaissGPR` (GPU) |
|
||||
| MAVLink I/O | `mavlink_io/protocol.py` | `MockMAVConnection` | `MAVLinkBridge` (pymavlink) |
|
||||
| Anchor Verifier | `anchor_verifier/protocol.py` | stub | stub (Phase 3) |
|
||||
| Safety State | `safety_state/protocol.py` | stub | stub (Phase 3) |
|
||||
| Flight Recorder | `flight_recorder/protocol.py` | stub | stub (Phase 4) |
|
||||
| Coord. Transforms | `coordinate_transforms/protocol.py` | stub | stub (Phase 5) |
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
39 AC з ідентифікаторами `AC-N.M` / `AC-NEW-N` у `_docs/00_problem/acceptance_criteria.md`.
|
||||
|
||||
Ключові порогові значення:
|
||||
|
||||
| Критерій | Поріг |
|
||||
|----------|-------|
|
||||
| Точність 80% кадрів | ≤ 50 м |
|
||||
| Точність 60% кадрів | ≤ 20 м |
|
||||
| End-to-end latency | < 400 мс |
|
||||
| Пам'ять (shared CPU/GPU) | < 8 GB |
|
||||
| Сторадж місії | < 64 GB |
|
||||
| GSD супутникового знімку | ≤ 0.5 м/px |
|
||||
| Час до першої позиції | ≤ 30 с |
|
||||
|
||||
---
|
||||
|
||||
## Архітектурні рішення (ADRs)
|
||||
|
||||
| ADR | Рішення |
|
||||
|-----|---------|
|
||||
| [0001](_docs/01_solution/decisions/0001-e2e-dataset-strategy.md) | E2E dataset strategy (EuRoC / VPAIR / MARS-LVIG) |
|
||||
| [0002](_docs/01_solution/decisions/0002-hexagonal-architecture-stage2.md) | Hexagonal / ports-and-adapters для Stage 2 |
|
||||
| [0003](_docs/01_solution/decisions/0003-hot-path-dataclasses-vs-pydantic.md) | `@dataclass(slots=True, frozen=True)` на hot path; Pydantic лише на межах |
|
||||
| [0004](_docs/01_solution/decisions/0004-stage2-as-independent-iteration.md) | Stage 2 як незалежна ітерація зі своїми фазами 1–6 |
|
||||
|
||||
---
|
||||
|
||||
## Roadmap Stage 2
|
||||
|
||||
| Фаза | Назва | Статус |
|
||||
|------|-------|--------|
|
||||
| 1 | Hexagonal Refactor | done (2026-05-11) |
|
||||
| 2 | AC Doc + Test Taxonomy + Observability Spine | done (2026-05-11) |
|
||||
| 3 | Safety Anchor State Machine + Geometry-Gated Anchor Verifier | planned |
|
||||
| 4 | Conditional VPR + Flight Data Recorder | planned |
|
||||
| 5 | MAVLink source labels + dual-channel scaffold | planned |
|
||||
| 6 | Azaion 10.05.2026 real-flight integration fixture | planned |
|
||||
|
||||
---
|
||||
|
||||
## Benchmark
|
||||
|
||||
```bash
|
||||
python scripts/benchmark_accuracy.py --frames 50
|
||||
@@ -222,106 +313,10 @@ python scripts/benchmark_accuracy.py --frames 50
|
||||
|
||||
| Критерій | Результат | Ліміт |
|
||||
|---------|-----------|-------|
|
||||
| 80% кадрів ≤ 50 м | ✅ 100% | ≥ 80% |
|
||||
| 60% кадрів ≤ 20 м | ✅ 100% | ≥ 60% |
|
||||
| p95 затримка | ✅ ~9 мс | < 400 мс |
|
||||
| VO дрейф за 1 км | ✅ ~11 м | < 100 м |
|
||||
|
||||
---
|
||||
|
||||
## Структура проєкту
|
||||
|
||||
```
|
||||
gps-denied-onboard/
|
||||
├── src/gps_denied/
|
||||
│ ├── app.py # FastAPI factory + lifespan
|
||||
│ ├── config.py # Pydantic Settings
|
||||
│ ├── api/routers/flights.py # REST + SSE endpoints
|
||||
│ ├── core/
|
||||
│ │ ├── eskf.py # 15-state ESKF (IMU+VO+satellite fusion)
|
||||
│ │ ├── processor.py # FlightProcessor + process_frame
|
||||
│ │ ├── vo.py # ORBVisualOdometry / CuVSLAMVisualOdometry
|
||||
│ │ ├── mavlink.py # MAVLinkBridge → GPS_INPUT → ArduPilot
|
||||
│ │ ├── satellite.py # SatelliteDataManager (local z/x/y tiles)
|
||||
│ │ ├── gpr.py # GlobalPlaceRecognition (Faiss/numpy)
|
||||
│ │ ├── metric.py # MetricRefinement (LiteSAM/XFeat + GSD)
|
||||
│ │ ├── graph.py # FactorGraphOptimizer (GTSAM ISAM2)
|
||||
│ │ ├── coordinates.py # CoordinateTransformer (ENU↔GPS↔pixel)
|
||||
│ │ ├── models.py # ModelManager + TRTInferenceEngine
|
||||
│ │ ├── benchmark.py # AccuracyBenchmark + SyntheticTrajectory
|
||||
│ │ ├── pipeline.py # ImageInputPipeline
|
||||
│ │ ├── rotation.py # ImageRotationManager
|
||||
│ │ ├── recovery.py # FailureRecoveryCoordinator
|
||||
│ │ └── chunk_manager.py # RouteChunkManager
|
||||
│ ├── schemas/ # Pydantic схеми (eskf, mavlink, vo, ...)
|
||||
│ ├── db/ # SQLAlchemy ORM + async repository
|
||||
│ └── utils/mercator.py # Web Mercator tile utilities
|
||||
├── tests/ # 22 test модулі
|
||||
├── scripts/
|
||||
│ └── benchmark_accuracy.py # CLI валідація точності
|
||||
├── Dockerfile # Multi-stage Python 3.11 image
|
||||
├── docker-compose.yml # Local dev
|
||||
├── docker-compose.sitl.yml # ArduPilot SITL harness
|
||||
├── .github/workflows/
|
||||
│ ├── ci.yml # lint + pytest + docker smoke (кожен push)
|
||||
│ └── sitl.yml # SITL integration (нічний / ручний)
|
||||
└── pyproject.toml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Компоненти
|
||||
|
||||
| ID | Назва | Файл | Dev | Jetson |
|
||||
|----|-------|------|-----|--------|
|
||||
| F04 | Satellite Data Manager | `core/satellite.py` | local tiles | local tiles |
|
||||
| F05 | Image Input Pipeline | `core/pipeline.py` | ✅ | ✅ |
|
||||
| F06 | Image Rotation Manager | `core/rotation.py` | ✅ | ✅ |
|
||||
| F07 | Sequential Visual Odometry | `core/vo.py` | ORB / Mono-Depth (scaled ORB) | cuVSLAM Mono-Depth |
|
||||
| F08 | Global Place Recognition | `core/gpr.py` | numpy | Faiss GPU |
|
||||
| F09 | Metric Refinement | `core/metric.py` | Mock | LiteSAM/XFeat TRT |
|
||||
| F10 | Factor Graph Optimizer | `core/graph.py` | Mock | GTSAM ISAM2 |
|
||||
| F11 | Failure Recovery | `core/recovery.py` | ✅ | ✅ |
|
||||
| F12 | Route Chunk Manager | `core/chunk_manager.py` | ✅ | ✅ |
|
||||
| F13 | Coordinate Transformer | `core/coordinates.py` | ✅ | ✅ |
|
||||
| F16 | Model Manager | `core/models.py` | Mock | TRT engines |
|
||||
| F17 | ESKF Sensor Fusion | `core/eskf.py` | ✅ | ✅ |
|
||||
| F18 | MAVLink I/O Bridge | `core/mavlink.py` | Mock | pymavlink |
|
||||
|
||||
---
|
||||
|
||||
## Що залишилось (наступні кроки)
|
||||
|
||||
### Sprint 1 — виконано (2026-04-18)
|
||||
|
||||
Див. план `docs/superpowers/plans/2026-04-18-sprint1-vo-migration.md` і design doc `docs/superpowers/specs/2026-04-18-oss-stack-tech-audit-design.md`.
|
||||
|
||||
- [x] **numpy pin** `>=1.26,<2.0` — NumPy 2.0 silently breaks GTSAM bindings (issue #2264)
|
||||
- [x] **CuVSLAMMonoDepthVisualOdometry** додано поряд з Inertial-варіантом. Dev/CI: ORB translation scaled by `depth_hint_m / 600.0`. Jetson: cuVSLAM Mono-Depth mode з barometric altitude як synthetic depth.
|
||||
- [x] **GlobalPlaceRecognition** явно маркований як AnyLoc-VLAD-DINOv2 baseline з selection rationale в docstring
|
||||
- [x] **GPS_INPUT encoding** покритий 12 unit-тестами проти `_eskf_to_gps_input` (degE7 lat/lon, ENU→NED velocity, ConfidenceTier → fix_type)
|
||||
- [x] **E2E regression guard** на EuRoC MH_01 для Mono-Depth (ATE 0.2046м, baseline незмінний)
|
||||
|
||||
### Sprint 2 — далі (захищений e2e-харнесом)
|
||||
|
||||
1. **Wire CuVSLAMMonoDepthVisualOdometry через E2EHarness** — harness зараз хардкодить `ORBVisualOdometry()`; додати `vo_backend` параметр щоб прогнати Mono-Depth через pipeline
|
||||
2. **Колапс дуплікатного коду з `CuVSLAMVisualOdometry`** (Inertial варіант) — видалити Inertial mode після Jetson validation
|
||||
3. **VPAIR unblock** — xfail (1770 км ATE) блокований відсутністю raw IMU + mock satellite index. Реальні MapTiler tiles + Mono-Depth з GT-altitude розблокують.
|
||||
4. **DINOv2-VLAD TRT FP16 engine** — реальний descriptor замість numpy L2 fallback. Satellite tiles через MapTiler MBTiles (offline).
|
||||
5. **aero-vloc benchmark** на наших nadir кадрах — підтвердити R@1 DINOv2-VLAD перед фіксацією Faiss index design
|
||||
6. **Аудит solution.md** — звірити `_docs/01_solution/solution.md` з реальним кодом (Inertial → Mono-Depth)
|
||||
7. **Реструктуризація** — `src/gps_denied/*` → `src/*`
|
||||
|
||||
### Критичні блокери (перевірити до наступного коду)
|
||||
- **Flight Controller processor**: H743 ✅ / F405 ❌ (silently ignores GPS_INPUT). Запитати постачальника.
|
||||
- **IMU rate через MAVLink**: за замовчуванням ArduPilot 50 Hz, для Mono-Inertial потрібно ≥100 Hz (`SR2_RAW_SENS`). Для Mono-Depth не критично.
|
||||
|
||||
### On-device (Jetson Orin Nano Super)
|
||||
1. Офлайн завантаження тайлів для зони місії → `{tile_dir}/z/x/y.png`
|
||||
2. Конвертація моделей: LiteSAM/XFeat PyTorch → ONNX → TRT FP16
|
||||
3. Запуск SITL: `docker compose -f docker-compose.sitl.yml up`
|
||||
4. Польотні дані: записати GPS + відео → порівняти ESKF-траєкторію з ground truth
|
||||
5. Калібрування: camera intrinsics + IMU noise density для конкретного апарату
|
||||
| 80% кадрів ≤ 50 м | 100% | ≥ 80% |
|
||||
| 60% кадрів ≤ 20 м | 100% | ≥ 60% |
|
||||
| p95 latency | ~9 мс | < 400 мс |
|
||||
| VO дрейф за 1 км | ~11 м | < 100 м |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,50 +1,931 @@
|
||||
# Position Accuracy
|
||||
# Acceptance Criteria
|
||||
|
||||
- The system should determine GPS coordinates of frame centers for 80% of photos within 50m error compared to real GPS
|
||||
- The system should determine GPS coordinates of frame centers for 60% of photos within 20m error compared to real GPS
|
||||
- Maximum cumulative VO drift between satellite correction anchors should be less than 100 meters
|
||||
- System should report a confidence score per position estimate (high = satellite-anchored, low = VO-extrapolated with drift)
|
||||
> **Last revised**: 2026-05-11 (Phase 2 / Stage 2 rewrite from try02 skeleton, validated against Stage 2 constraints).
|
||||
>
|
||||
> Revision log:
|
||||
> - 2026-05-11: Initial Stage 2 rewrite — adopted try02 AC vocabulary (AC-1.1..AC-8.6 + AC-NEW-1..AC-NEW-8), added per-AC schema (Statement / Numeric threshold / Rationale / Validation method / Test IDs / Implementing components / Status). Previous draft archived at `acceptance_criteria_draft.md`.
|
||||
> - 2026-05-01 (try02): AC-1.3 anchor-age reporting clarified; AC-2.1 split; AC-5.2 and AC-NEW-2 now require ArduPilot Plane SITL; AC-8.3 storage and AC-NEW-7 clarified.
|
||||
> - 2026-04-29 (try02): AC-3.5 and AC-NEW-8 added for visual blackout/cloud occlusion.
|
||||
> - 2026-04-26 (try02): AC-4.3 extended; AC-8.6 added; AC-NEW-7 added.
|
||||
> - 2026-04-25 (try02): initial AC list.
|
||||
|
||||
# Image Processing Quality
|
||||
## Schema
|
||||
|
||||
- Image Registration Rate > 95% for normal flight segments. The system can find enough matching features to confidently calculate the camera's 6-DoF pose and stitch that image into the trajectory
|
||||
- Mean Reprojection Error (MRE) < 1.0 pixels
|
||||
Every AC follows the same template:
|
||||
|
||||
# Resilience & Edge Cases
|
||||
- **AC-ID** — Short title
|
||||
|
||||
- The system should correctly continue work even in the presence of up to 350m outlier between 2 consecutive photos (due to tilt of the plane)
|
||||
- System should correctly continue work during sharp turns, where the next photo doesn't overlap at all or overlaps less than 5%. The next photo should be within 200m drift and at an angle of less than 70 degrees. Sharp-turn frames are expected to fail VO and should be handled by satellite-based re-localization
|
||||
- System should operate when UAV makes a sharp turn and next photos have no common points with previous route. It should figure out the location of the new route segment and connect it to the previous route. There could be more than 2 such disconnected segments, so this strategy must be core to the system
|
||||
- In case the system cannot determine the position of 3 consecutive frames by any means, it should send a re-localization request to the ground station operator via telemetry link. While waiting for operator input, the system continues attempting VO/IMU dead reckoning and the flight controller uses last known position + IMU extrapolation
|
||||
**Statement.** The system shall …
|
||||
|
||||
# Real-Time Onboard Performance
|
||||
**Numeric threshold.** Quantitative bound(s); measurement methodology.
|
||||
|
||||
- Less than 400ms end-to-end per frame: from camera capture to GPS coordinate output to the flight controller (camera shoots at ~3fps)
|
||||
- Memory usage should stay below 8GB shared memory (Jetson Orin Nano Super — CPU and GPU share the same 8GB LPDDR5 pool)
|
||||
- The system must output calculated GPS coordinates directly to the flight controller via MAVLink GPS_INPUT messages (using MAVSDK)
|
||||
- Position estimates are streamed to the flight controller frame-by-frame; the system does not batch or delay output
|
||||
- The system may refine previously calculated positions and send corrections to the flight controller as updated estimates
|
||||
**Rationale.** Risk if violated; tie-back to PROJECT.md core value.
|
||||
|
||||
# Startup & Failsafe
|
||||
**Validation method.** One of: `unit-test`, `integration-test`, `blackbox-replay`, `sitl-simulation`, `benchmark`, `deferred-hardware`. May list more than one.
|
||||
|
||||
- The system initializes using the last known valid GPS position from the flight controller before GPS denial begins
|
||||
- If the system completely fails to produce any position estimate for more than N seconds (TBD), the flight controller should fall back to IMU-only dead reckoning and the system should log the failure
|
||||
- On companion computer reboot mid-flight, the system should attempt to re-initialize from the flight controller's current IMU-extrapolated position
|
||||
**Test IDs.**
|
||||
- _populated by scripts/gen_ac_traceability.py_
|
||||
|
||||
# Ground Station & Telemetry
|
||||
**Implementing components.** `src/gps_denied/...` paths the AC binds to.
|
||||
|
||||
- Position estimates and confidence scores should be streamed to the ground station via telemetry link for operator situational awareness
|
||||
- The ground station can send commands to the onboard system (e.g., operator-assisted re-localization hint with approximate coordinates)
|
||||
- Output coordinates in WGS84 format
|
||||
**Status.** `active` / `deferred-stage3` / `superseded-by(AC-Z)`.
|
||||
|
||||
# Object Localization
|
||||
---
|
||||
|
||||
- Other onboard AI systems can request GPS coordinates of objects detected by the AI camera
|
||||
- The GPS-Denied system calculates object coordinates trigonometrically using: current UAV GPS position (from GPS-Denied), known AI camera angle, zoom, and current flight altitude. Flat terrain is assumed
|
||||
- Accuracy is consistent with the frame-center position accuracy of the GPS-Denied system
|
||||
## Position Accuracy
|
||||
|
||||
# Satellite Reference Imagery
|
||||
- **AC-1.1** — Position accuracy (50 m @ 80 %)
|
||||
|
||||
- Satellite reference imagery resolution must be at least 0.5 m/pixel, ideally 0.3 m/pixel
|
||||
- Satellite imagery for the operational area should be less than 2 years old where possible
|
||||
- Satellite imagery must be pre-processed and loaded onto the companion computer before flight. Offline preprocessing time is not time-critical (can take minutes/hours)
|
||||
**Statement.** The system shall determine GPS coordinates of frame centres within **50 m** of true GPS for **≥80 %** of photos in normal flight segments.
|
||||
|
||||
**Numeric threshold.**
|
||||
- `error_m = ‖estimate_xy − ground_truth_xy‖` (haversine, WGS84)
|
||||
- normal_flight_segment = nadir ±10° bank/pitch, ≥40 % overlap, daytime, no full visual blackout
|
||||
- PASS iff `count(error_m < 50) / count(normal_segment_frames) ≥ 0.80`
|
||||
- Window: per 5-minute rolling window over the full flight
|
||||
|
||||
**Rationale.** Below this threshold the flight controller cannot safely geofence; navigation in GPS-denied airspace is impossible. This is the core-value AC.
|
||||
|
||||
**Validation method.** `integration-test` + `benchmark` on AerialVL S03 and Azaion 10.05.2026 fixtures.
|
||||
|
||||
**Test IDs.**
|
||||
- _populated by scripts/gen_ac_traceability.py_
|
||||
|
||||
**Implementing components.**
|
||||
- `src/gps_denied/components/safety_state/`
|
||||
- `src/gps_denied/components/anchor_verifier/`
|
||||
- `src/gps_denied/core/eskf.py`
|
||||
|
||||
**Status.** active
|
||||
|
||||
- **AC-1.2** — Position accuracy (20 m @ 50 %)
|
||||
|
||||
**Statement.** The system shall determine GPS coordinates of frame centres within **20 m** of true GPS for **≥50 %** of photos in normal flight segments.
|
||||
|
||||
**Numeric threshold.**
|
||||
- Same measurement methodology as AC-1.1
|
||||
- PASS iff `count(error_m < 20) / count(normal_segment_frames) ≥ 0.50`
|
||||
|
||||
**Rationale.** Stretch target for precision operations; failing this but passing AC-1.1 is an acceptable Stage 2 outcome. Violating both degrades all geofence and targeting accuracy.
|
||||
|
||||
**Validation method.** `integration-test` + `benchmark` on AerialVL S03 and Azaion 10.05.2026 fixtures.
|
||||
|
||||
**Test IDs.**
|
||||
- _populated by scripts/gen_ac_traceability.py_
|
||||
|
||||
**Implementing components.**
|
||||
- `src/gps_denied/components/safety_state/`
|
||||
- `src/gps_denied/components/anchor_verifier/`
|
||||
- `src/gps_denied/core/eskf.py`
|
||||
|
||||
**Status.** active
|
||||
|
||||
- **AC-1.3** — Cumulative VO drift bound + anchor-age reporting
|
||||
|
||||
**Statement.** Maximum cumulative VO drift between two consecutive satellite-anchored fixes shall be **<100 m** (VO-only fallback) or **<50 m** (when IMU is fused). Every emitted estimate shall include `last_satellite_anchor_age_ms`; validation results shall be binned by anchor age, and the solution draft must define the maximum anchor age after which estimates are treated as degraded (`vo_extrapolated` or `dead_reckoned`) with monotonically growing covariance.
|
||||
|
||||
**Numeric threshold.**
|
||||
- VO-only fallback: `drift_m < 100 m`
|
||||
- IMU-fused: `drift_m < 50 m`
|
||||
- Drift measured as `‖VO-extrapolated centre − next anchor centre‖` at the moment of anchor fix
|
||||
|
||||
**Rationale.** Unbounded drift invalidates the position guarantee of AC-1.1. Anchor-age reporting is required for the traceability script and for Phase 3/6 observability. Cross-references: AC-NEW-1 (cold-start anchor age budget).
|
||||
|
||||
**Validation method.** `integration-test`
|
||||
|
||||
**Test IDs.**
|
||||
- _populated by scripts/gen_ac_traceability.py_
|
||||
|
||||
**Implementing components.**
|
||||
- `src/gps_denied/core/eskf.py`
|
||||
- `src/gps_denied/components/safety_state/`
|
||||
|
||||
**Status.** active (Phase 3 SAFE-03 wires `anchor_age_ms` field; Phase 2 records the AC text only)
|
||||
|
||||
- **AC-1.4** — Quantitative confidence score per estimate
|
||||
|
||||
**Statement.** The system shall report a **quantitative confidence score** per position estimate, comprising: the 95 % covariance ellipse semi-major axis in meters AND a categorical label `{satellite_anchored, vo_extrapolated, dead_reckoned}`.
|
||||
|
||||
**Numeric threshold.**
|
||||
- `source_label ∈ {satellite_anchored, vo_extrapolated, dead_reckoned}` (exhaustive; no other values permitted)
|
||||
- `cov_semi_major_m ≥ 0` (non-negative real)
|
||||
|
||||
**Rationale.** Without a machine-readable label the flight controller cannot distinguish a high-quality fix from dead reckoning; the FC applies no covariance-appropriate safety margin. Cross-references: AC-NEW-8 (source_label vocab used in blackout failsafe).
|
||||
|
||||
**Validation method.** `unit-test` (schema) + `integration-test`
|
||||
|
||||
**Test IDs.**
|
||||
- _populated by scripts/gen_ac_traceability.py_
|
||||
|
||||
**Implementing components.**
|
||||
- `src/gps_denied/components/safety_state/`
|
||||
- `src/gps_denied/hot_types/position_estimate.py`
|
||||
|
||||
**Status.** active
|
||||
|
||||
---
|
||||
|
||||
## Image Processing Quality
|
||||
|
||||
- **AC-2.1a** — VO registration rate
|
||||
|
||||
**Statement.** Frame-to-frame visual registration shall succeed for **>95 %** of normal flight segments (defined as: nadir flight ±10° bank/pitch, ≥40 % overlap with prior frame, daytime, usable texture, no full visual blackout).
|
||||
|
||||
**Numeric threshold.**
|
||||
- `count(vo_success) / count(normal_segment_frames) > 0.95`
|
||||
|
||||
**Rationale.** VO failure rate above 5 % causes excessive dead reckoning in steady-state conditions, violating AC-1.3 drift bound.
|
||||
|
||||
**Validation method.** `integration-test`
|
||||
|
||||
**Test IDs.**
|
||||
- _populated by scripts/gen_ac_traceability.py_
|
||||
|
||||
**Implementing components.**
|
||||
- `src/gps_denied/components/vio/`
|
||||
- `src/gps_denied/pipeline/orchestrator.py`
|
||||
|
||||
**Status.** active
|
||||
|
||||
- **AC-2.1b** — Satellite-anchor registration
|
||||
|
||||
**Statement.** Cross-domain UAV-photo to satellite/cache registration is measured separately from AC-2.1a and must satisfy AC-1.1/AC-1.2 position accuracy, AC-2.2 cross-domain MRE, AC-8.2 freshness, and AC-8.6 retrieval behavior on season-matched tiles.
|
||||
|
||||
**Numeric threshold.**
|
||||
- Satellite-anchor MRE: **<2.5 px** (see AC-2.2)
|
||||
- Anchor acceptance rate: bounded by AC-1.1 (≥80 % of frames within 50 m)
|
||||
|
||||
**Rationale.** Satellite registration rate and quality must be tracked independently of VO to isolate failure modes (tile staleness vs. VO failure vs. cross-domain mismatch).
|
||||
|
||||
**Validation method.** `integration-test` + `benchmark`
|
||||
|
||||
**Test IDs.**
|
||||
- _populated by scripts/gen_ac_traceability.py_
|
||||
|
||||
**Implementing components.**
|
||||
- `src/gps_denied/components/satellite_matcher/`
|
||||
- `src/gps_denied/components/anchor_verifier/`
|
||||
|
||||
**Status.** active (pending-phase-4 (VPR-03))
|
||||
|
||||
- **AC-2.2** — Mean Reprojection Error
|
||||
|
||||
**Statement.** Mean Reprojection Error (MRE) shall be: **<1.0 px** for VO frame-to-frame homography on overlapping aerial pairs; **<2.5 px** for satellite-anchored cross-domain (UAV photo ↔ ortho satellite tile) registration.
|
||||
|
||||
**Numeric threshold.**
|
||||
- VO MRE: `mre_px < 1.0`
|
||||
- Satellite MRE: `mre_px < 2.5`
|
||||
|
||||
**Rationale.** MRE above these thresholds indicates degenerate homography; the resulting position shift can exceed 10 m per pixel of MRE at 0.5 m/px resolution. Exceeding satellite MRE violates AC-1.1.
|
||||
|
||||
**Validation method.** `integration-test`
|
||||
|
||||
**Test IDs.**
|
||||
- _populated by scripts/gen_ac_traceability.py_
|
||||
|
||||
**Implementing components.**
|
||||
- `src/gps_denied/components/satellite_matcher/metric_refinement.py`
|
||||
- `src/gps_denied/components/anchor_verifier/`
|
||||
|
||||
**Status.** active
|
||||
|
||||
---
|
||||
|
||||
## Resilience & Edge Cases
|
||||
|
||||
- **AC-3.1** — Outlier tolerance
|
||||
|
||||
**Statement.** The system shall correctly continue work in the presence of up to **350 m** outliers between two consecutive photos (caused by airframe tilt up to ±20°).
|
||||
|
||||
**Numeric threshold.**
|
||||
- Maximum inter-frame displacement handled: 350 m
|
||||
- System continues producing valid estimates after the outlier frame
|
||||
|
||||
**Rationale.** Fixed-wing tilt events create apparent ground displacement beyond normal VO search radius; failure to handle them causes tracking loss on routine maneuvers.
|
||||
|
||||
**Validation method.** `integration-test`
|
||||
|
||||
**Test IDs.**
|
||||
- _populated by scripts/gen_ac_traceability.py_
|
||||
|
||||
**Implementing components.**
|
||||
- `src/gps_denied/core/recovery.py`
|
||||
- `src/gps_denied/components/satellite_matcher/`
|
||||
|
||||
**Status.** active (pending-phase-4 (VPR-01))
|
||||
|
||||
- **AC-3.2** — Sharp-turn handling
|
||||
|
||||
**Statement.** The system shall correctly continue work during sharp turns where the next photo overlaps **<5 %** with the previous, drifts **<200 m**, and changes heading **<70°**. Sharp-turn frames are expected to fail VO and shall be handled by satellite-based re-localization (place recognition over the satellite tile cache).
|
||||
|
||||
**Numeric threshold.**
|
||||
- Overlap threshold: `<5 %`
|
||||
- Drift after turn: `<200 m`
|
||||
- Heading change: `<70°`
|
||||
|
||||
**Rationale.** Fixed-wing survey patterns include 180° turn-arounds with near-zero overlap. Without satellite re-localization the system drifts unbounded on every headland.
|
||||
|
||||
**Validation method.** `integration-test`
|
||||
|
||||
**Test IDs.**
|
||||
- _populated by scripts/gen_ac_traceability.py_
|
||||
|
||||
**Implementing components.**
|
||||
- `src/gps_denied/core/recovery.py`
|
||||
- `src/gps_denied/components/satellite_matcher/`
|
||||
- `src/gps_denied/core/factor_graph.py`
|
||||
|
||||
**Status.** active (pending-phase-4 (VPR-01))
|
||||
|
||||
- **AC-3.3** — Disconnected segments
|
||||
|
||||
**Statement.** The system shall handle **≥3 disconnected segments** per flight, connecting each new segment to the previous trajectory via global descriptor retrieval + RANSAC pose-graph relocalization. This is a core capability, not a degraded mode.
|
||||
|
||||
**Numeric threshold.**
|
||||
- Minimum disconnected segments handled per flight: 3
|
||||
- Each segment successfully reconnected to the global trajectory
|
||||
|
||||
**Rationale.** Multi-strip survey missions have multiple heading reversals with full visual discontinuity. If the system treats this as exceptional, it fails on every second strip.
|
||||
|
||||
**Validation method.** `integration-test`
|
||||
|
||||
**Test IDs.**
|
||||
- _populated by scripts/gen_ac_traceability.py_
|
||||
|
||||
**Implementing components.**
|
||||
- `src/gps_denied/core/recovery.py`
|
||||
- `src/gps_denied/components/satellite_matcher/`
|
||||
- `src/gps_denied/core/factor_graph.py`
|
||||
|
||||
**Status.** active
|
||||
|
||||
- **AC-3.4** — Re-localization request trigger
|
||||
|
||||
**Statement.** When the system cannot determine position for **≥3 consecutive frames AND ≥2 s**, it shall send a re-localization request to the ground station via telemetry. While waiting, it continues VO/IMU dead reckoning and the flight controller uses last known position + IMU extrapolation.
|
||||
|
||||
**Numeric threshold.**
|
||||
- Dual trigger: `consecutive_frames_failed ≥ 3 AND time_failed_s ≥ 2`
|
||||
- Re-loc request must be emitted within one processing cycle of threshold crossing
|
||||
|
||||
**Rationale.** A single trigger threshold (frame count only) can fire on sub-second bursts; dual-trigger prevents spurious requests during transient VO failures.
|
||||
|
||||
**Validation method.** `blackbox-replay` + `sitl-simulation`
|
||||
|
||||
**Test IDs.**
|
||||
- _populated by scripts/gen_ac_traceability.py_
|
||||
|
||||
**Implementing components.**
|
||||
- `src/gps_denied/core/recovery.py`
|
||||
- `src/gps_denied/components/mavlink_io/pymavlink_bridge.py`
|
||||
|
||||
**Status.** active
|
||||
|
||||
- **AC-3.5** — Visual blackout mode switch
|
||||
|
||||
**Statement.** During temporary **visual blackout** where the navigation camera provides no usable ground signal (e.g., clouds/occlusion/whiteout) while GPS is denied or spoofed, the system shall switch to `{dead_reckoned}` within **≤1 processed frame OR ≤400 ms**, reject the spoofed GPS as an estimator input, and propagate position solely from the last trusted state + flight-controller IMU/attitude/airspeed/altitude inputs until visual or satellite anchoring recovers. During this mode, covariance shall grow monotonically, `GPS_INPUT.horiz_accuracy` shall not under-report the 95 % covariance semi-major axis, and QGroundControl shall receive a `VISUAL_BLACKOUT_IMU_ONLY` status at **1–2 Hz**. Cross-references: AC-NEW-8 (extended blackout failsafe budget).
|
||||
|
||||
**Numeric threshold.**
|
||||
- Mode switch latency: `≤1 processed frame OR ≤400 ms`
|
||||
- QGC status rate: `1–2 Hz`
|
||||
- Covariance: monotonically non-decreasing in blackout mode
|
||||
|
||||
**Rationale.** A cloud/whiteout period removes all visual correction exactly when spoofed GPS cannot be trusted. Pretending a stale visual position remains valid is operationally dangerous.
|
||||
|
||||
**Validation method.** `blackbox-replay` + `sitl-simulation`
|
||||
|
||||
**Test IDs.**
|
||||
- _populated by scripts/gen_ac_traceability.py_
|
||||
|
||||
**Implementing components.**
|
||||
- `src/gps_denied/components/safety_state/`
|
||||
- `src/gps_denied/components/mavlink_io/pymavlink_bridge.py`
|
||||
- `src/gps_denied/core/eskf.py`
|
||||
|
||||
**Status.** active (pending-phase-3 (SAFE-02))
|
||||
|
||||
---
|
||||
|
||||
## Real-Time Onboard Performance
|
||||
|
||||
- **AC-4.1** — End-to-end latency
|
||||
|
||||
**Statement.** End-to-end latency from camera capture to GPS coordinate output to the flight controller shall be **<400 ms p95**. Up to ~10 % of frames may be dropped under sustained load (skip-allowed). Heavy global VPR / cross-domain re-ranking shall be conditional, not part of the steady-state per-frame path.
|
||||
|
||||
**Numeric threshold.**
|
||||
- p95 end-to-end latency: `<400 ms`
|
||||
- Frame-drop budget: `≤10 %`
|
||||
|
||||
**Rationale.** ArduPilot EKF failsafe fires after ~3 s without a GPS update at 5 Hz; 400 ms p95 keeps the GPS_INPUT stream healthy. Beyond 400 ms, EKF position uncertainty grows fast enough to trigger safety modes.
|
||||
|
||||
**Validation method.** `benchmark`
|
||||
|
||||
**Test IDs.**
|
||||
- _populated by scripts/gen_ac_traceability.py_
|
||||
|
||||
**Implementing components.**
|
||||
- `src/gps_denied/pipeline/orchestrator.py`
|
||||
- `src/gps_denied/components/vio/`
|
||||
- `src/gps_denied/components/satellite_matcher/`
|
||||
|
||||
**Status.** active
|
||||
|
||||
- **AC-4.2** — Memory budget
|
||||
|
||||
**Statement.** Memory usage shall remain below **8 GB** shared on Jetson Orin Nano Super (CPU and GPU share the same 8 GB LPDDR5 pool).
|
||||
|
||||
**Numeric threshold.**
|
||||
- Peak shared memory: `<8 GB`
|
||||
|
||||
**Rationale.** Exceeding 8 GB triggers OOM-killer; the system crashes mid-flight. No recovery path exists without a reboot.
|
||||
|
||||
**Validation method.** `benchmark`
|
||||
|
||||
**Test IDs.**
|
||||
- _populated by scripts/gen_ac_traceability.py_
|
||||
|
||||
**Implementing components.**
|
||||
- `src/gps_denied/pipeline/orchestrator.py`
|
||||
- `src/gps_denied/components/gpr/`
|
||||
|
||||
**Status.** active (pending-phase-4 (FDR-01))
|
||||
|
||||
- **AC-4.3** — MAVLink output channel (GPS_INPUT primary)
|
||||
|
||||
**Statement.** The system shall output its position estimate to the flight controller via **two parallel MAVLink channels**, both emitted by **pymavlink**: Primary (`GPS_INPUT` targeting ArduPilot `GPS1_TYPE=14`) and Auxiliary (`ODOMETRY` when EKF emits fix with full 6-DoF covariance and quality > `VISO_QUAL_MIN`). FC source priorities configured so GPS_INPUT remains the failover path if ODOMETRY trips a parameter gate.
|
||||
|
||||
**v1 scope clause (added 2026-04-26)**: v1 ships **GPS_INPUT only**; the ODOMETRY auxiliary channel is intentionally **disabled** in v1 because feeding both `GPS_INPUT` and `ODOMETRY` for overlapping axes triggers ArduPilot EKF3 double-fusion bugs (issues #30076 / #32506). `EK3_SRC1_*=GPS+Compass`; ODOMETRY emission re-enables in v1.1 once F-T9 SITL confirms PR #30080-class clean source-switching. Tests therefore assert v1 emits GPS_INPUT only and that ODOMETRY is *intentionally absent* on the wire.
|
||||
|
||||
**Numeric threshold.**
|
||||
- v1: `GPS_INPUT` present on wire; `ODOMETRY` absent
|
||||
- FC parameter: `GPS1_TYPE=14`; quality gate: `VISO_QUAL_MIN`
|
||||
|
||||
**Rationale.** EKF3 double-fusion causes divergent position estimates that cannot be diagnosed in the field. v1 single-channel keeps the behavior predictable until the upstream ArduPilot fix is confirmed. (See MAVOUT-02 requirement.)
|
||||
|
||||
**Validation method.** `blackbox-replay` (channel absence) + `sitl-simulation` (FC ingest)
|
||||
|
||||
**Test IDs.**
|
||||
- _populated by scripts/gen_ac_traceability.py_
|
||||
|
||||
**Implementing components.**
|
||||
- `src/gps_denied/components/mavlink_io/pymavlink_bridge.py`
|
||||
- `src/gps_denied/pipeline/orchestrator.py`
|
||||
|
||||
**Status.** active (v1: GPS_INPUT only; MAVOUT-02 scaffolds ODOMETRY behind feature flag)
|
||||
|
||||
- **AC-4.4** — Frame-by-frame streaming
|
||||
|
||||
**Statement.** Position estimates are streamed to the flight controller frame-by-frame; the system shall not batch or delay output.
|
||||
|
||||
**Numeric threshold.**
|
||||
- Every processed frame produces a `GPS_INPUT` emit within the AC-4.1 latency budget
|
||||
- No buffering / batching of position output
|
||||
|
||||
**Rationale.** Batching introduces burst latency; ArduPilot's EKF treats inter-packet gaps as data-rate drops, degrading its own estimate quality.
|
||||
|
||||
**Validation method.** `sitl-simulation`
|
||||
|
||||
**Test IDs.**
|
||||
- _populated by scripts/gen_ac_traceability.py_
|
||||
|
||||
**Implementing components.**
|
||||
- `src/gps_denied/components/mavlink_io/pymavlink_bridge.py`
|
||||
|
||||
**Status.** active
|
||||
|
||||
- **AC-4.5** — Estimate refinement / corrections
|
||||
|
||||
**Statement.** The system may refine previously calculated positions and send corrections to the flight controller as updated estimates.
|
||||
|
||||
**Numeric threshold.**
|
||||
- Correction delta: no hard limit (any refinement may be sent)
|
||||
- FC must not reject corrections; system must not suppress them
|
||||
|
||||
**Rationale.** Factor-graph back-end refines past poses when new satellite anchors arrive; sending the refined estimate keeps the FC's EKF state consistent with our back-end.
|
||||
|
||||
**Validation method.** `sitl-simulation`
|
||||
|
||||
**Test IDs.**
|
||||
- _populated by scripts/gen_ac_traceability.py_
|
||||
|
||||
**Implementing components.**
|
||||
- `src/gps_denied/pipeline/orchestrator.py`
|
||||
- `src/gps_denied/components/mavlink_io/pymavlink_bridge.py`
|
||||
|
||||
**Status.** active (pending-phase-3 (SAFE-04))
|
||||
|
||||
---
|
||||
|
||||
## Startup & Failsafe
|
||||
|
||||
- **AC-5.1** — Initialization from FC EKF
|
||||
|
||||
**Statement.** The system shall initialise using the last known valid GPS position from the flight controller's EKF, plus IMU-extrapolated position at the moment of GPS denial.
|
||||
|
||||
**Numeric threshold.**
|
||||
- Initialization must complete before first `GPS_INPUT` emit
|
||||
- Initial position: FC EKF position at denial onset, not a default/zero position
|
||||
|
||||
**Rationale.** Starting from zero coordinates puts the FC in a state where GPS_INPUT contradicts the last real GPS; the EKF rejects the first several estimates, creating a cold-start gap in coverage.
|
||||
|
||||
**Validation method.** `sitl-simulation`
|
||||
|
||||
**Test IDs.**
|
||||
- _populated by scripts/gen_ac_traceability.py_
|
||||
|
||||
**Implementing components.**
|
||||
- `src/gps_denied/components/mavlink_io/`
|
||||
- `src/gps_denied/core/recovery.py`
|
||||
|
||||
**Status.** active (pending-phase-3 (SAFE-01))
|
||||
|
||||
- **AC-5.2** — Failsafe timeout
|
||||
|
||||
**Statement.** If the system fails to produce any position estimate for **>3 s**, the flight controller shall fall back to IMU-only dead reckoning and the system shall log the failure. Because ArduPilot failsafe timing depends on vehicle type and parameters, this fallback behavior must be verified specifically in **ArduPilot Plane SITL** with the production parameter set; Copter defaults are reference evidence only.
|
||||
|
||||
**Numeric threshold.**
|
||||
- Failsafe trigger: `gap_s > 3`
|
||||
- Validation scope: ArduPilot Plane SITL (not Copter)
|
||||
|
||||
**Rationale.** Plane and Copter have different EKF lane-switch thresholds; verifying on Copter SITL only leaves the Plane-specific failsafe path untested.
|
||||
|
||||
**Validation method.** `sitl-simulation` (ArduPilot Plane SITL specifically)
|
||||
|
||||
**Test IDs.**
|
||||
- _populated by scripts/gen_ac_traceability.py_
|
||||
|
||||
**Implementing components.**
|
||||
- `src/gps_denied/components/mavlink_io/`
|
||||
- `src/gps_denied/core/recovery.py`
|
||||
|
||||
**Status.** active
|
||||
|
||||
- **AC-5.3** — Mid-flight reboot recovery
|
||||
|
||||
**Statement.** On companion computer reboot mid-flight, the system shall attempt to re-initialise from the flight controller's current IMU-extrapolated position. See AC-NEW-1 for the cold-start time-to-first-fix budget.
|
||||
|
||||
**Numeric threshold.**
|
||||
- Recovery attempt must start within `AC-NEW-1` TTFF budget (<30 s)
|
||||
- Recovery position: FC IMU-extrapolated position at reboot time
|
||||
|
||||
**Rationale.** Brown-out / watchdog resets are realistic on 8-hour missions; the UAV must not enter an unrecoverable state when the companion reboots.
|
||||
|
||||
**Validation method.** `sitl-simulation`
|
||||
|
||||
**Test IDs.**
|
||||
- _populated by scripts/gen_ac_traceability.py_
|
||||
|
||||
**Implementing components.**
|
||||
- `src/gps_denied/components/mavlink_io/`
|
||||
- `src/gps_denied/core/recovery.py`
|
||||
|
||||
**Status.** active (pending-phase-3 (SAFE-01))
|
||||
|
||||
---
|
||||
|
||||
## Ground Station & Telemetry
|
||||
|
||||
- **AC-6.1** — GCS position stream
|
||||
|
||||
**Statement.** Position estimates and confidence scores shall be streamed to **QGroundControl** via the MAVLink telemetry link. High-rate (per-frame) content stays on the local link for forensics; the GCS link is downsampled to **1–2 Hz** for situational awareness.
|
||||
|
||||
**Numeric threshold.**
|
||||
- GCS stream rate: `1–2 Hz`
|
||||
- Local forensic stream: per-frame (AC-4.1 rate)
|
||||
|
||||
**Rationale.** QGC's map view cannot render per-frame updates usefully; downsampling reduces bandwidth without losing situational awareness. Full-rate data is preserved in FDR (AC-NEW-3).
|
||||
|
||||
**Validation method.** `sitl-simulation` (QGC downsample observable on the wire)
|
||||
|
||||
**Test IDs.**
|
||||
- _populated by scripts/gen_ac_traceability.py_
|
||||
|
||||
**Implementing components.**
|
||||
- `src/gps_denied/components/mavlink_io/pymavlink_bridge.py`
|
||||
|
||||
**Status.** active (pending-phase-5 (MAVOUT-01))
|
||||
|
||||
- **AC-6.2** — Operator re-localization commands
|
||||
|
||||
**Statement.** The ground station can send commands to the onboard system (e.g., operator-assisted re-localization hint with approximate coordinates) via STATUSTEXT, NAMED_VALUE_FLOAT, or a custom MAVLink dialect.
|
||||
|
||||
**Numeric threshold.**
|
||||
- Command must be received and acted upon within one processing cycle
|
||||
- Protocol: STATUSTEXT / NAMED_VALUE_FLOAT / custom dialect (implementation choice)
|
||||
|
||||
**Rationale.** Operator-assisted re-localization is the fallback when automatic re-localization fails (AC-3.4 trigger path); without this channel the operator cannot intervene.
|
||||
|
||||
**Validation method.** `sitl-simulation`
|
||||
|
||||
**Test IDs.**
|
||||
- _populated by scripts/gen_ac_traceability.py_
|
||||
|
||||
**Implementing components.**
|
||||
- `src/gps_denied/components/mavlink_io/pymavlink_bridge.py`
|
||||
|
||||
**Status.** active (pending-phase-5 (MAVOUT-03))
|
||||
|
||||
- **AC-6.3** — WGS84 coordinate format
|
||||
|
||||
**Statement.** Output coordinates are in **WGS84** format (matches GPS_INPUT spec).
|
||||
|
||||
**Numeric threshold.**
|
||||
- All lat/lon fields in GPS_INPUT in WGS84 decimal degrees × 1e7 (MAVLink convention)
|
||||
- No local coordinate systems exported to FC
|
||||
|
||||
**Rationale.** GPS_INPUT spec requires WGS84; using a local ENU frame crashes the EKF.
|
||||
|
||||
**Validation method.** `unit-test` (WGS84 coord format)
|
||||
|
||||
**Test IDs.**
|
||||
- _populated by scripts/gen_ac_traceability.py_
|
||||
|
||||
**Implementing components.**
|
||||
- `src/gps_denied/components/mavlink_io/pymavlink_bridge.py`
|
||||
- `src/gps_denied/core/eskf.py`
|
||||
|
||||
**Status.** active
|
||||
|
||||
---
|
||||
|
||||
## Object Localization (AI Camera)
|
||||
|
||||
- **AC-7.1** — AI camera localization accuracy
|
||||
|
||||
**Statement.** Other onboard AI systems may request GPS coordinates of objects detected by the AI camera. Localization accuracy is **consistent with the frame-center accuracy of the GPS-Denied system in level flight (bank/pitch <5°)**. In maneuvering flight, ground-projection error is bounded by `altitude × |sin(unknown_bank_or_pitch)|` and the system shall publish that bound alongside the estimate.
|
||||
|
||||
**Numeric threshold.**
|
||||
- Level flight (bank/pitch <5°): accuracy consistent with AC-1.1 / AC-1.2
|
||||
- Maneuvering flight: bound reported = `altitude_m × |sin(attitude_rad)|`
|
||||
|
||||
**Rationale.** Object localization inherits all position error from the GPS-Denied system plus the camera projection uncertainty; the bound must be propagated honestly.
|
||||
|
||||
**Validation method.** `unit-test` (trig) + `integration-test`
|
||||
|
||||
**Test IDs.**
|
||||
- _populated by scripts/gen_ac_traceability.py_
|
||||
|
||||
**Implementing components.**
|
||||
- `src/gps_denied/components/coordinate_transforms/`
|
||||
|
||||
**Status.** active (pending-phase-5 (MAVOUT-04))
|
||||
|
||||
- **AC-7.2** — AI camera trigonometric computation
|
||||
|
||||
**Statement.** The system computes object coordinates trigonometrically using: current UAV GPS position (from GPS-Denied), known AI-camera gimbal angle, zoom, and current flight altitude. Flat-terrain assumption applies.
|
||||
|
||||
**Numeric threshold.**
|
||||
- Flat-terrain assumption: explicit, documented per-request
|
||||
- Input: GPS-Denied position + gimbal angle + zoom + altitude (all mandatory fields)
|
||||
|
||||
**Rationale.** Without explicit flat-terrain documentation, operators may use the output for terrain-varying tasks where it is not valid.
|
||||
|
||||
**Validation method.** `unit-test` (trig) + `integration-test`
|
||||
|
||||
**Test IDs.**
|
||||
- _populated by scripts/gen_ac_traceability.py_
|
||||
|
||||
**Implementing components.**
|
||||
- `src/gps_denied/components/coordinate_transforms/`
|
||||
|
||||
**Status.** active (pending-phase-5 (MAVOUT-04))
|
||||
|
||||
---
|
||||
|
||||
## Satellite Reference Imagery
|
||||
|
||||
- **AC-8.1** — Satellite imagery source and resolution
|
||||
|
||||
**Statement.** Satellite reference imagery is provided by the **Azaion Suite Satellite Service** (a separate component of the Suite). The runtime onboard system consumes this service through an offline tile cache interface; it does **not** call commercial providers directly. Required resolution at the cache interface: **at least 0.5 m/pixel, ideally 0.3 m/pixel**.
|
||||
|
||||
**Numeric threshold.**
|
||||
- Minimum resolution: `0.5 m/px`
|
||||
- Ideal resolution: `0.3 m/px`
|
||||
|
||||
**Rationale.** Below 0.5 m/px, keypoint density drops below the minimum for reliable homography; the satellite-anchor MRE (AC-2.2) cannot be met.
|
||||
|
||||
**Validation method.** `integration-test`
|
||||
|
||||
**Test IDs.**
|
||||
- _populated by scripts/gen_ac_traceability.py_
|
||||
|
||||
**Implementing components.**
|
||||
- `src/gps_denied/components/satellite_matcher/local_tile_loader.py`
|
||||
|
||||
**Status.** active (pending-phase-4 (FDR-03))
|
||||
|
||||
- **AC-8.2** — Tile freshness by sector
|
||||
|
||||
**Statement.** Satellite tiles consumed at runtime shall be: **<6 months old** for active-conflict sectors; **<12 months old** for stable rear sectors. System shall reject or downgrade-confidence on tiles older than these thresholds (see AC-NEW-6).
|
||||
|
||||
**Numeric threshold.**
|
||||
- Active-conflict sectors: `age_months < 6`
|
||||
- Stable rear sectors: `age_months < 12`
|
||||
|
||||
**Rationale.** Stale tiles in active-conflict sectors (building destruction, cratering) produce confident-but-wrong satellite anchors — worse than no anchor.
|
||||
|
||||
**Validation method.** `integration-test`
|
||||
|
||||
**Test IDs.**
|
||||
- _populated by scripts/gen_ac_traceability.py_
|
||||
|
||||
**Implementing components.**
|
||||
- `src/gps_denied/components/anchor_verifier/`
|
||||
- `src/gps_denied/components/satellite_matcher/local_tile_loader.py`
|
||||
|
||||
**Status.** active (pending-phase-3 (VERIFY-03))
|
||||
|
||||
- **AC-8.3** — Pre-flight imagery loading
|
||||
|
||||
**Statement.** Satellite imagery for the operational area shall be **pre-loaded and pre-processed** onto the companion computer before flight. Offline preprocessing time is not time-critical (minutes/hours). Pre-extracted tile descriptors (SuperPoint keypoints/descriptors and DINOv2-VLAD global descriptors) are part of the cache and count against the storage budget unless the solution draft defines a separate descriptor/index budget.
|
||||
|
||||
**Numeric threshold.**
|
||||
- All operational-area tiles available before `GPS_INPUT` is first emitted
|
||||
- Storage: within 64 GB FDR cap (AC-NEW-3); descriptor index explicitly budgeted
|
||||
|
||||
**Rationale.** In-flight tile downloads require connectivity that is unavailable in GPS-denied airspace.
|
||||
|
||||
**Validation method.** `integration-test`
|
||||
|
||||
**Test IDs.**
|
||||
- _populated by scripts/gen_ac_traceability.py_
|
||||
|
||||
**Implementing components.**
|
||||
- `src/gps_denied/components/satellite_matcher/local_tile_loader.py`
|
||||
- `src/gps_denied/components/gpr/`
|
||||
|
||||
**Status.** active (pending-phase-4 (FDR-02))
|
||||
|
||||
- **AC-8.4** — Mid-flight tile generation and write-back
|
||||
|
||||
**Statement.** During flight, the system shall continuously orthorectify navigation-camera frames into tiles aligned with the basemap projection and store them in the local cache, **deduplicated** so each ground sector is stored at most once (latest/highest-quality tile wins). On landing, the companion computer shall upload newly generated tiles back to the Azaion Suite Satellite Service so the next mission cache contains imagery refreshed by the previous flight.
|
||||
|
||||
**Numeric threshold.**
|
||||
- Deduplication: one tile per ground sector (latest wins)
|
||||
- Upload: on-landing, before next mission
|
||||
- Write eligibility gated by `can_persist_tile` (see SAFE-05)
|
||||
|
||||
**Rationale.** Without write-back, the cache becomes stale relative to conflict-sector ground truth after the first mission; every subsequent mission anchors against pre-conflict imagery.
|
||||
|
||||
**Validation method.** `integration-test`
|
||||
|
||||
**Test IDs.**
|
||||
- _populated by scripts/gen_ac_traceability.py_
|
||||
|
||||
**Implementing components.**
|
||||
- `src/gps_denied/components/satellite_matcher/`
|
||||
- `src/gps_denied/components/flight_recorder/`
|
||||
|
||||
**Status.** deferred-stage3 (pending-phase-4 (FDR-05)) — mid-flight orthorectification + write-back is a Stage 3 deliverable per REQUIREMENTS.md parking lot
|
||||
|
||||
- **AC-8.5** — Storage policy (no raw frame retention)
|
||||
|
||||
**Statement.** The system shall **not** retain raw navigation-camera frames or AI-camera frames as part of normal operation. Tiles are the only persistent imagery artifact. Forensic exception: a low-rate (**≤0.1 Hz**) thumbnail log of frames that failed tile generation may be retained for debugging within the FDR budget (AC-NEW-3).
|
||||
|
||||
**Numeric threshold.**
|
||||
- Raw frame retention: zero bytes in normal operation
|
||||
- Forensic thumbnail rate: `≤0.1 Hz`
|
||||
|
||||
**Rationale.** Raw nav-cam frames at 3 Hz × 8 hours exceeds any reasonable storage budget and provides no operational value once tiles exist.
|
||||
|
||||
**Validation method.** `integration-test` + `benchmark`
|
||||
|
||||
**Test IDs.**
|
||||
- _populated by scripts/gen_ac_traceability.py_
|
||||
|
||||
**Implementing components.**
|
||||
- `src/gps_denied/components/flight_recorder/`
|
||||
- `src/gps_denied/pipeline/orchestrator.py`
|
||||
|
||||
**Status.** active (pending-phase-4 (FDR-04))
|
||||
|
||||
- **AC-8.6** — VPR retrieval unit and change-robustness
|
||||
|
||||
**Statement.** The Visual Place Recognition (VPR) FAISS index shall be built over ground-footprint-sized "VPR chunks" (~**600–800 m** at deployment altitude with **40–50 % overlap**), decoupled from the slippy-XYZ storage tile (z=20). VPR shall be **multi-scale** (fine-scale z=20-derived + coarser-scale z=17 or z=18 for active-conflict change-robustness). VPR top-K shall be **dynamically sized**: K=5 (stable, σ_xy ≤ 20 m), K=20 (active-conflict), K=50 (expanding-window fallback). VPR shall be **invoked conditionally** — not on every frame; DINOv2 forward runs only on re-loc triggers (cold start, sharp turn AC-3.2, σ_xy > 50 m, VO failure ≥2 frames, disconnected segment AC-3.3).
|
||||
|
||||
**Numeric threshold.**
|
||||
- Chunk size: `600–800 m` at deployment altitude
|
||||
- Chunk overlap: `40–50 %`
|
||||
- Multi-scale: z=20 (fine) + z=17 or z=18 (coarse)
|
||||
- Dynamic K: K=5 / K=20 / K=50 per sector/covariance
|
||||
- VPR invocation: conditional on re-loc triggers only
|
||||
|
||||
**Rationale.** Unconditional DINOv2 inference at every frame violates AC-4.1 (400 ms budget). Change-robust coarse index prevents anchor failure in active-conflict sectors where the fine-scale tile may not match post-conflict ground truth.
|
||||
|
||||
**Validation method.** `integration-test` + `benchmark`
|
||||
|
||||
**Test IDs.**
|
||||
- _populated by scripts/gen_ac_traceability.py_
|
||||
|
||||
**Implementing components.**
|
||||
- `src/gps_denied/components/gpr/`
|
||||
- `src/gps_denied/core/chunk_manager.py`
|
||||
|
||||
**Status.** active (pending-phase-4 (VPR-01))
|
||||
|
||||
---
|
||||
|
||||
## Extended Operational AC
|
||||
|
||||
### AC-NEW-1 — Time-to-first-fix on cold start
|
||||
|
||||
- **AC-NEW-1** — Time-to-first-fix on cold start
|
||||
|
||||
**Statement.** From companion-computer boot, the system shall emit its first valid `GPS_INPUT` message in **<30 s**, given an IMU-extrapolated initial position handed over from the flight controller's EKF.
|
||||
|
||||
**Numeric threshold.**
|
||||
- TTFF: `95th percentile < 30 s` (measured over 50× cold reboots)
|
||||
- Implementation drivers: TRT engines built at install time; CUDA/TRT init <5 s; FAISS index loaded before MAVLink connect
|
||||
|
||||
**Rationale.** A mid-flight reboot at 60 km/h creates ~500 m IMU dead-reckoning drift in 30 s; the EKF can absorb this when the first fix arrives. Beyond 30 s the drift compounds. Cross-references: AC-1.3 (anchor-age at cold start); AC-5.3 (reboot recovery).
|
||||
|
||||
**Validation method.** `deferred-hardware` (cold-boot bench requires Jetson hardware; cold-boot 50× with simulated FC-pose input)
|
||||
|
||||
**Test IDs.**
|
||||
- _deferred_hardware_
|
||||
|
||||
**Implementing components.**
|
||||
- `src/gps_denied/pipeline/orchestrator.py`
|
||||
- `src/gps_denied/core/models.py`
|
||||
|
||||
**Status.** deferred-hardware-validation (Stage 3 on Jetson)
|
||||
|
||||
### AC-NEW-2 — Spoofing-promotion latency
|
||||
|
||||
- **AC-NEW-2** — Spoofing-promotion latency
|
||||
|
||||
**Statement.** When the flight controller signals GPS denial or spoofing (ArduPilot fix-loss / EKF lane-switch event), the GPS-Denied system shall promote its own estimate to the FC's primary GPS source within **<3 s**.
|
||||
|
||||
**Numeric threshold.**
|
||||
- Promotion latency: `95th percentile < 3 s`
|
||||
- Trigger: real-GPS health rolling average below threshold for ≥1 s
|
||||
|
||||
**Rationale.** Without this gate, the FC may continue following a spoofed real-GPS source while our valid estimate sits idle. 3 s is short enough to prevent malicious heading changes but long enough to avoid single-frame anomaly false positives.
|
||||
|
||||
**Validation method.** `sitl-simulation` (ArduPilot Plane SITL: inject false GPS_RAW_INT, measure time from spoof onset to promotion)
|
||||
|
||||
**Test IDs.**
|
||||
- _populated by scripts/gen_ac_traceability.py_
|
||||
|
||||
**Implementing components.**
|
||||
- `src/gps_denied/components/mavlink_io/pymavlink_bridge.py`
|
||||
|
||||
**Status.** active
|
||||
|
||||
### AC-NEW-3 — Flight Data Recorder storage budget
|
||||
|
||||
- **AC-NEW-3** — Flight Data Recorder storage budget
|
||||
|
||||
**Statement.** The system shall retain to non-volatile storage, per flight: per-frame position estimates with covariance and source-label, IMU traces from the FC at full rate, all emitted `GPS_INPUT` frames, MAVLink raw stream (tlog), system health (CPU/GPU/temp/throttle), tiles generated mid-flight (AC-8.4), and a ≤0.1 Hz thumbnail log of failed-tile frames. **Raw nav-cam frames and AI-cam frames are NOT retained** (AC-8.5). Storage cap **64 GB / flight**; recorder rolls over (oldest segment dropped first) after cap.
|
||||
|
||||
**Numeric threshold.**
|
||||
- Storage cap: `≤64 GB per flight`
|
||||
- Forensic thumbnail rate: `≤0.1 Hz`
|
||||
- Rollover policy: oldest segment dropped; rollover event logged
|
||||
|
||||
**Rationale.** Without bounded storage, an 8-hour mission fills any NVMe and silently drops forensic data. The 64 GB cap is sized for 8-hour mission with tiles + IMU + telemetry, leaving headroom for the persistent tile cache (AC-8.3).
|
||||
|
||||
**Validation method.** `integration-test` (rollover behavior) + `benchmark` (synthetic 8-hour load) + `deferred-hardware` (real Jetson NVMe throughput in Stage 3)
|
||||
|
||||
**Test IDs.**
|
||||
- _populated by scripts/gen_ac_traceability.py_
|
||||
|
||||
**Implementing components.**
|
||||
- `src/gps_denied/components/flight_recorder/`
|
||||
|
||||
**Status.** active (CI scope) + deferred-hardware-validation (Stage 3)
|
||||
|
||||
### AC-NEW-4 — False-position safety budget
|
||||
|
||||
- **AC-NEW-4** — False-position safety budget
|
||||
|
||||
**Statement.**
|
||||
- P(reported estimate error > **500 m**) **< 0.1 %** per flight.
|
||||
- P(reported estimate error > **1 km**) **< 0.01 %** per flight.
|
||||
|
||||
**Numeric threshold.**
|
||||
- `P(error > 500 m) < 0.001`
|
||||
- `P(error > 1 km) < 0.0001`
|
||||
- Measurement: Monte Carlo over AerialVL S03 + Mavic + Azaion 10.05.2026, ≥100 simulated flights
|
||||
|
||||
**Rationale.** A single 1-km-off GPS_INPUT can fly the UAV outside the geofence in seconds. EKF covariance must be calibrated, not optimistic; outlier rejection (Mahalanobis gate) is mandatory.
|
||||
|
||||
**Validation method.** `benchmark` (Monte Carlo over AerialVL S03 + Mavic + Azaion 10.05.2026)
|
||||
|
||||
**Test IDs.**
|
||||
- _populated by scripts/gen_ac_traceability.py_
|
||||
|
||||
**Implementing components.**
|
||||
- `src/gps_denied/core/eskf.py`
|
||||
- `src/gps_denied/components/anchor_verifier/`
|
||||
|
||||
**Status.** active (pending-phase-3 (VERIFY-01))
|
||||
|
||||
### AC-NEW-5 — Operational environmental envelope
|
||||
|
||||
- **AC-NEW-5** — Operational environmental envelope
|
||||
|
||||
**Statement.** Operating temperature **−20 °C to +50 °C**; vibration/shock per RTCA DO-160G low-altitude UAV-class envelope. The cooling solution shall sustain the **25 W** power mode at the upper temperature bound for the full **8-hour duty cycle** without thermal throttling.
|
||||
|
||||
**Numeric threshold.**
|
||||
- Temperature range: `−20 °C to +50 °C`
|
||||
- Power mode: `25 W` sustained
|
||||
- Duration: `8 hours`
|
||||
- Pass: no thermal throttling events during hot-soak
|
||||
|
||||
**Rationale.** Without this, all latency/accuracy ACs are conditional on a benign thermal day. Eastern Ukraine summers easily exceed +35 °C ambient inside a UAV bay; without active cooling, Jetson throttles to 15 W and AC-4.1 (400 ms budget) collapses.
|
||||
|
||||
**Validation method.** `deferred-hardware` (hot-soak chamber at +50 °C for 8 h + cold-soak at −20 °C to AC-NEW-1 TTFF)
|
||||
|
||||
**Test IDs.**
|
||||
- _deferred_hardware_
|
||||
|
||||
**Implementing components.**
|
||||
- Hardware cooling assembly (outside software scope)
|
||||
- `src/gps_denied/components/flight_recorder/` (thermal event logging via FDR)
|
||||
|
||||
**Status.** deferred-hardware-validation (Stage 3 on Jetson)
|
||||
|
||||
### AC-NEW-6 — Imagery freshness enforcement
|
||||
|
||||
- **AC-NEW-6** — Imagery freshness enforcement
|
||||
|
||||
**Statement.** The system shall reject (or downgrade confidence on) any satellite tile whose capture date violates AC-8.2 (>6 months old in active-conflict sectors; >12 months old in stable rear sectors). Tiles generated mid-flight (AC-8.4) and not yet uploaded to the Suite Satellite Service are timestamped with the current flight date and treated as fresh.
|
||||
|
||||
**Numeric threshold.**
|
||||
- Confidence weight: `1.0` within freshness budget; linearly decayed to `0.0` over a 30-day grace zone; hard reject beyond grace
|
||||
- Sector classification: provided pre-flight in operational area definition
|
||||
|
||||
**Rationale.** Stale satellite tiles are the dominant cross-view-matching failure mode in active-conflict sectors. A confident match against a stale tile is worse than no match.
|
||||
|
||||
**Validation method.** `unit-test` (rejection curve) + `integration-test` (cache integration)
|
||||
|
||||
**Test IDs.**
|
||||
- _populated by scripts/gen_ac_traceability.py_
|
||||
|
||||
**Implementing components.**
|
||||
- `src/gps_denied/components/anchor_verifier/`
|
||||
- `src/gps_denied/components/satellite_matcher/local_tile_loader.py`
|
||||
|
||||
**Status.** active (pending-phase-3 (VERIFY-03))
|
||||
|
||||
### AC-NEW-7 — Cache-poisoning safety budget
|
||||
|
||||
- **AC-NEW-7** — Cache-poisoning safety budget
|
||||
|
||||
**Statement.** Per flight, across all onboard tiles written by the in-flight ortho-tile generator:
|
||||
- P(onboard tile geo-misaligned > **30 m**) **< 1 %**.
|
||||
- P(onboard tile geo-misaligned > **100 m**) **< 0.1 %**.
|
||||
|
||||
Component-1b (tile generator) enforces: σ_xy ≤ 5 m hard write gate (`trust_level = candidate`); σ_xy ≤ 3 m for full quality; σ_xy ∈ (3, 5] m marked `trust_level = soft`. Multi-flight voting (N≥2 independent flights confirm consistent geo-alignment within X m) is a Suite Satellite Service dependency for final promotion to trusted basemap. Cross-references: AC-8.4 (write-back); AC-NEW-4 (single-flight false-position budget).
|
||||
|
||||
**Numeric threshold.**
|
||||
- `P(geo-misalignment > 30 m) < 0.01`
|
||||
- `P(geo-misalignment > 100 m) < 0.001`
|
||||
- Onboard trust_level gate: σ_xy ≤ 5 m (candidate), σ_xy ≤ 3 m (full quality)
|
||||
- Multi-flight voting: N≥2 flights (Service-side, deferred)
|
||||
|
||||
**Rationale.** Onboard tiles feed back into the Suite Satellite Service basemap (AC-8.4); a confidently-bad EKF pose can write a misaligned tile that becomes the next flight's satellite anchor, compounding cross-flight error beyond what AC-NEW-4 covers.
|
||||
|
||||
**Validation method.**
|
||||
- `integration-test`: onboard `trust_level` gating (σ_xy ≤ 5 m hard, ≤ 3 m full quality)
|
||||
- `deferred-hardware`: multi-flight voting (N≥2 flights, Service-side voting layer outside this build)
|
||||
|
||||
**Test IDs.**
|
||||
- _populated by scripts/gen_ac_traceability.py_ (for onboard trust_level gating)
|
||||
- _deferred_hardware_ (for multi-flight voting)
|
||||
|
||||
**Implementing components.**
|
||||
- `src/gps_denied/components/satellite_matcher/`
|
||||
- `src/gps_denied/components/anchor_verifier/`
|
||||
|
||||
**Status.** active (onboard gate — Stage 2); deferred-hardware-validation (multi-flight voting — Stage 3)
|
||||
|
||||
### AC-NEW-8 — Visual blackout + GPS spoofing degraded-mode budget
|
||||
|
||||
- **AC-NEW-8** — Visual blackout + GPS spoofing degraded-mode budget
|
||||
|
||||
**Statement.** When the navigation camera is fully unusable for visual localization and the flight controller simultaneously reports GPS denial/spoofing, the onboard system shall:
|
||||
- continue emitting `GPS_INPUT` from IMU-only propagation for **up to 30 s** after the last trusted visual/satellite anchor, unless the estimator covariance exceeds the fail threshold earlier;
|
||||
- label every estimate `{dead_reckoned}` and set `fix_type=2` or lower when the 95 % covariance semi-major axis exceeds **100 m**;
|
||||
- emit `fix_type=0`, `horiz_accuracy=999.0`, and `STATUSTEXT: VISUAL_BLACKOUT_FAILSAFE` when the 95 % covariance semi-major axis exceeds **500 m** OR visual blackout exceeds **30 s** without a trusted re-anchor;
|
||||
- never promote spoofed real-GPS measurements back into the estimator during blackout unless the FC GPS health has been stable and non-spoofed for **≥10 s** and a visual/satellite consistency check has succeeded. Cross-references: AC-1.4 (source_label vocab); AC-3.5 (blackout mode switch).
|
||||
|
||||
**Numeric threshold.**
|
||||
- IMU-only emit duration: `≤30 s`
|
||||
- `dead_reckoned` + `fix_type=2` when `σ_xy > 100 m`
|
||||
- `fix_type=0` + `horiz_accuracy=999.0` + `STATUSTEXT VISUAL_BLACKOUT_FAILSAFE` when `σ_xy > 500 m` OR blackout > 30 s
|
||||
- GPS re-promotion gate: ≥10 s non-spoofed health + visual/satellite consistency check
|
||||
|
||||
**Rationale.** A cloud/whiteout period removes all visual correction exactly when spoofed GPS cannot be trusted. Honest IMU-only dead reckoning with rapidly growing uncertainty is the only safe behavior.
|
||||
|
||||
**Validation method.** `blackbox-replay` + `sitl-simulation` (inject 5 s, 15 s, 35 s full-camera blackout while spoofing GPS_RAW_INT; assert mode transition ≤400 ms, spoofed GPS ignored, covariance grows monotonically, GPS_INPUT fields degrade at thresholds, recovery only after trusted anchor or 10 s GPS-health gate)
|
||||
|
||||
**Test IDs.**
|
||||
- _populated by scripts/gen_ac_traceability.py_
|
||||
|
||||
**Implementing components.**
|
||||
- `src/gps_denied/components/safety_state/`
|
||||
- `src/gps_denied/core/eskf.py`
|
||||
- `src/gps_denied/components/mavlink_io/pymavlink_bridge.py`
|
||||
|
||||
**Status.** active (pending-phase-3 (SAFE-02))
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Position Accuracy
|
||||
|
||||
- The system should determine GPS coordinates of frame centers for 80% of photos within 50m error compared to real GPS
|
||||
- The system should determine GPS coordinates of frame centers for 60% of photos within 20m error compared to real GPS
|
||||
- Maximum cumulative VO drift between satellite correction anchors should be less than 100 meters
|
||||
- System should report a confidence score per position estimate (high = satellite-anchored, low = VO-extrapolated with drift)
|
||||
|
||||
# Image Processing Quality
|
||||
|
||||
- Image Registration Rate > 95% for normal flight segments. The system can find enough matching features to confidently calculate the camera's 6-DoF pose and stitch that image into the trajectory
|
||||
- Mean Reprojection Error (MRE) < 1.0 pixels
|
||||
|
||||
# Resilience & Edge Cases
|
||||
|
||||
- The system should correctly continue work even in the presence of up to 350m outlier between 2 consecutive photos (due to tilt of the plane)
|
||||
- System should correctly continue work during sharp turns, where the next photo doesn't overlap at all or overlaps less than 5%. The next photo should be within 200m drift and at an angle of less than 70 degrees. Sharp-turn frames are expected to fail VO and should be handled by satellite-based re-localization
|
||||
- System should operate when UAV makes a sharp turn and next photos have no common points with previous route. It should figure out the location of the new route segment and connect it to the previous route. There could be more than 2 such disconnected segments, so this strategy must be core to the system
|
||||
- In case the system cannot determine the position of 3 consecutive frames by any means, it should send a re-localization request to the ground station operator via telemetry link. While waiting for operator input, the system continues attempting VO/IMU dead reckoning and the flight controller uses last known position + IMU extrapolation
|
||||
|
||||
# Real-Time Onboard Performance
|
||||
|
||||
- Less than 400ms end-to-end per frame: from camera capture to GPS coordinate output to the flight controller (camera shoots at ~3fps)
|
||||
- Memory usage should stay below 8GB shared memory (Jetson Orin Nano Super — CPU and GPU share the same 8GB LPDDR5 pool)
|
||||
- The system must output calculated GPS coordinates directly to the flight controller via MAVLink GPS_INPUT messages (using MAVSDK)
|
||||
- Position estimates are streamed to the flight controller frame-by-frame; the system does not batch or delay output
|
||||
- The system may refine previously calculated positions and send corrections to the flight controller as updated estimates
|
||||
|
||||
# Startup & Failsafe
|
||||
|
||||
- The system initializes using the last known valid GPS position from the flight controller before GPS denial begins
|
||||
- If the system completely fails to produce any position estimate for more than N seconds (TBD), the flight controller should fall back to IMU-only dead reckoning and the system should log the failure
|
||||
- On companion computer reboot mid-flight, the system should attempt to re-initialize from the flight controller's current IMU-extrapolated position
|
||||
|
||||
# Ground Station & Telemetry
|
||||
|
||||
- Position estimates and confidence scores should be streamed to the ground station via telemetry link for operator situational awareness
|
||||
- The ground station can send commands to the onboard system (e.g., operator-assisted re-localization hint with approximate coordinates)
|
||||
- Output coordinates in WGS84 format
|
||||
|
||||
# Object Localization
|
||||
|
||||
- Other onboard AI systems can request GPS coordinates of objects detected by the AI camera
|
||||
- The GPS-Denied system calculates object coordinates trigonometrically using: current UAV GPS position (from GPS-Denied), known AI camera angle, zoom, and current flight altitude. Flat terrain is assumed
|
||||
- Accuracy is consistent with the frame-center position accuracy of the GPS-Denied system
|
||||
|
||||
# Satellite Reference Imagery
|
||||
|
||||
- Satellite reference imagery resolution must be at least 0.5 m/pixel, ideally 0.3 m/pixel
|
||||
- Satellite imagery for the operational area should be less than 2 years old where possible
|
||||
- Satellite imagery must be pre-processed and loaded onto the companion computer before flight. Offline preprocessing time is not time-critical (can take minutes/hours)
|
||||
+14
-1
@@ -17,9 +17,11 @@ dependencies = [
|
||||
"diskcache>=5.6",
|
||||
"numpy>=1.26,<2.0", # NumPy 2.0 silently breaks GTSAM Python bindings (issue #2264)
|
||||
"opencv-python-headless>=4.9,<4.11", # 4.11+ requires numpy>=2.0 (incompatible with GTSAM)
|
||||
"orjson>=3.10",
|
||||
"gtsam>=4.3a0",
|
||||
"pymavlink>=2.4",
|
||||
"pyyaml>=6.0",
|
||||
"structlog>=25.1,<26",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -53,8 +55,19 @@ select = ["E", "F", "I", "W"]
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
# --strict-markers makes unregistered @pytest.mark.<x> fail collection rather than warn.
|
||||
# Per Phase 2 / TEST-02 contract; do not weaken without an explicit Phase 2 retrospective entry.
|
||||
addopts = "--strict-markers"
|
||||
markers = [
|
||||
"e2e: end-to-end test against a real dataset",
|
||||
# Phase 2 / TEST-01 taxonomy
|
||||
"unit: pure-math or single-class test; only mocks; no I/O / no real DB / no real engines / no SITL; runs in <1s",
|
||||
"integration: cross-subsystem (in-memory SQLite, ASGI transport, full FlightProcessor wiring across >=3 real components); no external process",
|
||||
"blackbox: validates an external contract (e.g. MAVLink GPS_INPUT wire encoding per MAVLink #232) without a live producer",
|
||||
"sitl: requires ARDUPILOT_SITL_HOST env var; talks to an ArduPilot SITL process over MAVLink; nightly-only",
|
||||
"e2e: full-pipeline run against a real dataset (EuRoC, VPAIR, MARS-LVIG, Azaion) or its synthetic stand-in via E2EHarness; nightly-only",
|
||||
# Phase 2 / TEST-03 traceability
|
||||
"ac(ac_id): link test to one or more Acceptance Criteria (e.g. AC-1.1, AC-NEW-3); validated against _docs/00_problem/acceptance_criteria.md by scripts/gen_ac_traceability.py",
|
||||
# Pre-existing (kept verbatim)
|
||||
"e2e_slow: e2e test that takes > 2 minutes, nightly-only",
|
||||
"needs_dataset: test requires an external dataset to be downloaded",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Generate .planning/AC-TRACEABILITY.md from pytest collection.
|
||||
|
||||
Bidirectional AC<->test traceability per Phase 2 / AC-06.
|
||||
|
||||
Forward orphan : declared AC with no test -> visible + (with --check) exit 1
|
||||
Backward orphan : test references unknown AC ID -> visible + (with --check) exit 1
|
||||
Deferred-hw AC : `validation_method: deferred-hardware` in the AC entry
|
||||
-> excluded from orphan check, rendered as "DEFERRED (hardware)"
|
||||
|
||||
Usage:
|
||||
python scripts/gen_ac_traceability.py # write matrix, exit 0
|
||||
python scripts/gen_ac_traceability.py --check # CI: exit 1 on drift
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
log = logging.getLogger("gen_ac_traceability")
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
AC_DOC = ROOT / "_docs" / "00_problem" / "acceptance_criteria.md"
|
||||
OUT_MD = ROOT / ".planning" / "AC-TRACEABILITY.md"
|
||||
|
||||
_AC_HEADER_RE = re.compile(r"^\s*-\s*\*\*(AC-(?:\d+\.\d+[a-z]?|NEW-\d+))\*\*")
|
||||
_DEFERRED_RE = re.compile(r"deferred-hardware", re.IGNORECASE)
|
||||
# Phase 2 / AC-doc Status annotation: `pending-phase-3 (SAFE-01)`, `pending-phase-4 (FDR-02)`, etc.
|
||||
# Tracks ACs whose test coverage is intentionally deferred to a later phase.
|
||||
_PENDING_RE = re.compile(r"pending-phase-\d+", re.IGNORECASE)
|
||||
|
||||
|
||||
def collect_acs_from_doc() -> dict[str, dict]:
|
||||
"""Parse _docs/00_problem/acceptance_criteria.md.
|
||||
|
||||
Returns: {ac_id: {"deferred": bool}}.
|
||||
An AC is `deferred` if the literal token `deferred-hardware` (case-insensitive)
|
||||
appears anywhere in the block between this AC's header and the next AC's header
|
||||
(or EOF).
|
||||
"""
|
||||
if not AC_DOC.exists():
|
||||
log.error("AC doc not found: %s", AC_DOC)
|
||||
sys.exit(2)
|
||||
|
||||
acs: dict[str, dict] = {}
|
||||
current_id: str | None = None
|
||||
for line in AC_DOC.read_text(encoding="utf-8").splitlines():
|
||||
m = _AC_HEADER_RE.match(line)
|
||||
if m:
|
||||
current_id = m.group(1)
|
||||
acs[current_id] = {"deferred": False, "deferred_reason": None}
|
||||
continue
|
||||
if current_id is not None:
|
||||
if _DEFERRED_RE.search(line):
|
||||
acs[current_id]["deferred"] = True
|
||||
acs[current_id]["deferred_reason"] = "hardware"
|
||||
elif _PENDING_RE.search(line):
|
||||
acs[current_id]["deferred"] = True
|
||||
pm = _PENDING_RE.search(line)
|
||||
acs[current_id]["deferred_reason"] = pm.group(0)
|
||||
return acs
|
||||
|
||||
|
||||
def collect_acs_from_tests() -> dict[str, list[str]]:
|
||||
"""Run `pytest --collect-only --ac-dump=<tmp>` and parse the JSON.
|
||||
|
||||
Returns: {ac_id: [test_nodeid, ...]}.
|
||||
"""
|
||||
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tmp:
|
||||
tmp_path = Path(tmp.name)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["pytest", "--collect-only", "-q", f"--ac-dump={tmp_path}", "tests/"],
|
||||
cwd=ROOT,
|
||||
check=False, # collection may report 0 errors but exit non-zero on warnings -- handle below
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
# Collection errors are a fatal traceability problem; surface stderr.
|
||||
if result.returncode != 0 and not tmp_path.exists():
|
||||
log.error("pytest --collect-only failed:\n%s", result.stderr or result.stdout)
|
||||
sys.exit(2)
|
||||
if not tmp_path.exists() or tmp_path.stat().st_size == 0:
|
||||
return {}
|
||||
return json.loads(tmp_path.read_text(encoding="utf-8"))
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def render_md(doc_acs: dict[str, dict], test_map: dict[str, list[str]]) -> str:
|
||||
"""Render the AC-TRACEABILITY.md content."""
|
||||
declared = sorted(doc_acs)
|
||||
tested = set(test_map)
|
||||
declared_set = set(declared)
|
||||
|
||||
lines: list[str] = [
|
||||
"# AC Traceability Matrix",
|
||||
"",
|
||||
"> Auto-generated by `scripts/gen_ac_traceability.py`. Do not edit by hand.",
|
||||
"> Run `python scripts/gen_ac_traceability.py` to regenerate after AC doc or test edits.",
|
||||
"",
|
||||
f"**ACs declared in acceptance_criteria.md:** {len(declared)}",
|
||||
f"**ACs covered by at least one test:** {len(declared_set & tested)}",
|
||||
f"**ACs deferred (hardware or pending-phase):** {sum(1 for a in declared if doc_acs[a]['deferred'])}",
|
||||
"",
|
||||
"## AC -> Test mapping",
|
||||
"",
|
||||
"| AC ID | Test count | Tests | Status |",
|
||||
"|-------|-----------|-------|--------|",
|
||||
]
|
||||
for ac_id in declared:
|
||||
tests = test_map.get(ac_id, [])
|
||||
meta = doc_acs[ac_id]
|
||||
if meta["deferred"]:
|
||||
reason = meta.get("deferred_reason") or "hardware"
|
||||
status = f"DEFERRED ({reason})"
|
||||
elif tests:
|
||||
status = "OK"
|
||||
else:
|
||||
status = "**ORPHAN -- no test**"
|
||||
tests_str = "<br>".join(f"`{t}`" for t in tests) if tests else "_none_"
|
||||
lines.append(f"| {ac_id} | {len(tests)} | {tests_str} | {status} |")
|
||||
|
||||
unknown = sorted(tested - declared_set)
|
||||
if unknown:
|
||||
lines += ["", "## Tests reference unknown AC IDs (backward orphans)", ""]
|
||||
for ac_id in unknown:
|
||||
for nid in test_map[ac_id]:
|
||||
lines.append(f"- `{ac_id}` <- `{nid}`")
|
||||
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
p.add_argument(
|
||||
"--check", "--strict", action="store_true",
|
||||
help="Exit 1 if any non-deferred AC has 0 tests, or any test references an AC ID not in the doc.",
|
||||
)
|
||||
args = p.parse_args()
|
||||
|
||||
doc_acs = collect_acs_from_doc()
|
||||
test_map = collect_acs_from_tests()
|
||||
out = render_md(doc_acs, test_map)
|
||||
OUT_MD.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUT_MD.write_text(out, encoding="utf-8")
|
||||
log.info("wrote %s (%d AC, %d tagged, %d deferred)",
|
||||
OUT_MD.relative_to(ROOT),
|
||||
len(doc_acs),
|
||||
sum(1 for a in doc_acs if test_map.get(a)),
|
||||
sum(1 for a in doc_acs if doc_acs[a]["deferred"]))
|
||||
|
||||
if args.check:
|
||||
bad_orphans = sorted(
|
||||
a for a, meta in doc_acs.items() if not meta["deferred"] and not test_map.get(a)
|
||||
)
|
||||
unknown = sorted(set(test_map) - set(doc_acs))
|
||||
if bad_orphans or unknown:
|
||||
log.error("AC traceability drift detected:")
|
||||
for a in bad_orphans:
|
||||
log.error(" ORPHAN AC (no test): %s", a)
|
||||
for a in unknown:
|
||||
log.error(" UNKNOWN AC ID in test: %s", a)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -8,6 +8,7 @@ from fastapi import FastAPI
|
||||
from gps_denied import __version__
|
||||
from gps_denied.api.routers import flights
|
||||
from gps_denied.config import RuntimeConfig
|
||||
from gps_denied.obs import configure_logging
|
||||
from gps_denied.pipeline import build_pipeline
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -17,6 +18,9 @@ logger = logging.getLogger(__name__)
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Initialise core pipeline components on startup via build_pipeline."""
|
||||
cfg = RuntimeConfig()
|
||||
# OBS-01: configure structlog ONCE before pipeline construction.
|
||||
# Non-hot-path logging (api/, db/) continues to use stdlib until Phase 6.
|
||||
configure_logging(env=cfg.env)
|
||||
processor = build_pipeline(env=cfg.env, config=cfg)
|
||||
|
||||
# Retrieve MAVLink bridge from processor internals for lifecycle management
|
||||
|
||||
@@ -7,12 +7,12 @@ The legacy import path (gps_denied.core.mavlink) re-exports everything here.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
from typing import Callable, Optional
|
||||
|
||||
import numpy as np
|
||||
import structlog
|
||||
|
||||
from gps_denied.schemas import GPSPoint
|
||||
from gps_denied.schemas.eskf import ConfidenceTier, ESKFState, IMUMeasurement
|
||||
@@ -22,7 +22,7 @@ from gps_denied.schemas.mavlink import (
|
||||
TelemetryMessage,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pymavlink conditional import
|
||||
@@ -30,11 +30,11 @@ logger = logging.getLogger(__name__)
|
||||
try:
|
||||
from pymavlink import mavutil as _mavutil # type: ignore
|
||||
_PYMAVLINK_AVAILABLE = True
|
||||
logger.info("pymavlink available — real MAVLink connection enabled")
|
||||
logger.info("pymavlink_available", mode="real_mavlink")
|
||||
except ImportError:
|
||||
_mavutil = None # type: ignore
|
||||
_PYMAVLINK_AVAILABLE = False
|
||||
logger.info("pymavlink not available — using MockMAVConnection (dev/CI mode)")
|
||||
logger.info("pymavlink_unavailable", fallback="MockMAVConnection")
|
||||
|
||||
# GPS epoch offset from Unix epoch (seconds)
|
||||
_GPS_EPOCH_OFFSET = 315_964_800
|
||||
@@ -211,7 +211,7 @@ class MAVLinkBridge:
|
||||
asyncio.create_task(self._imu_receive_loop(), name="mav_imu_input"),
|
||||
asyncio.create_task(self._telemetry_loop(), name="mav_telemetry"),
|
||||
]
|
||||
logger.info("MAVLinkBridge started (conn=%s, %g Hz)", self.connection_string, self.output_hz)
|
||||
logger.info("mavlink_bridge_started", conn=self.connection_string, output_hz=self.output_hz)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Cancel background tasks and close connection."""
|
||||
@@ -223,8 +223,7 @@ class MAVLinkBridge:
|
||||
if self._conn:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
logger.info("MAVLinkBridge stopped. sent=%d imu_rx=%d",
|
||||
self._sent_count, self._recv_imu_count)
|
||||
logger.info("mavlink_bridge_stopped", sent=self._sent_count, imu_rx=self._recv_imu_count)
|
||||
|
||||
def build_gps_input(self) -> Optional[GPSInputMessage]:
|
||||
"""Build GPSInputMessage from current ESKF state (public, for testing)."""
|
||||
@@ -238,6 +237,7 @@ class MAVLinkBridge:
|
||||
|
||||
async def _gps_output_loop(self) -> None:
|
||||
"""Send GPS_INPUT at output_hz. MAV-01 / MAV-02."""
|
||||
log = logger.bind(task="gps_output_loop")
|
||||
interval = 1.0 / self.output_hz
|
||||
while self._running:
|
||||
try:
|
||||
@@ -250,7 +250,7 @@ class MAVLinkBridge:
|
||||
if self._consecutive_failures >= self.max_consecutive_failures:
|
||||
self._send_reloc_request()
|
||||
except Exception as exc:
|
||||
logger.warning("GPS output loop error: %s", exc)
|
||||
log.warning("gps_output_loop_error", error=str(exc))
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
def _send_gps_input(self, msg: GPSInputMessage) -> None:
|
||||
@@ -289,7 +289,7 @@ class MAVLinkBridge:
|
||||
lon=msg.lon,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send GPS_INPUT: %s", exc)
|
||||
logger.error("gps_input_send_failed", error=str(exc))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# MAV-03: IMU receive loop
|
||||
@@ -297,6 +297,7 @@ class MAVLinkBridge:
|
||||
|
||||
async def _imu_receive_loop(self) -> None:
|
||||
"""Receive ATTITUDE/RAW_IMU and invoke ESKF callback. MAV-03."""
|
||||
log = logger.bind(task="imu_receive_loop")
|
||||
while self._running:
|
||||
try:
|
||||
raw = self._recv_imu()
|
||||
@@ -305,7 +306,7 @@ class MAVLinkBridge:
|
||||
if self._on_imu:
|
||||
self._on_imu(raw)
|
||||
except Exception as exc:
|
||||
logger.warning("IMU receive loop error: %s", exc)
|
||||
log.warning("imu_receive_loop_error", error=str(exc))
|
||||
await asyncio.sleep(0.01) # poll at ~100 Hz; blocks throttled by recv_match timeout
|
||||
|
||||
def _recv_imu(self) -> Optional[IMUMeasurement]:
|
||||
@@ -334,7 +335,7 @@ class MAVLinkBridge:
|
||||
timestamp=t,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("IMU recv error: %s", exc)
|
||||
logger.debug("imu_recv_error", error=str(exc))
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -364,9 +365,9 @@ class MAVLinkBridge:
|
||||
)
|
||||
else:
|
||||
self._conn.named_value_float_send(time=t_boot_ms, name=name, value=value)
|
||||
logger.warning("Re-localisation request sent (failures=%d)", self._consecutive_failures)
|
||||
logger.warning("reloc_request_sent", consecutive_failures=self._consecutive_failures)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send reloc request: %s", exc)
|
||||
logger.error("reloc_request_send_failed", error=str(exc))
|
||||
|
||||
def _build_reloc_request(self) -> RelocalizationRequest:
|
||||
last_lat, last_lon = None, None
|
||||
@@ -393,13 +394,14 @@ class MAVLinkBridge:
|
||||
|
||||
async def _telemetry_loop(self) -> None:
|
||||
"""Send confidence + drift at 1 Hz. MAV-05."""
|
||||
log = logger.bind(task="telemetry_loop")
|
||||
interval = 1.0 / self.telemetry_hz
|
||||
while self._running:
|
||||
try:
|
||||
self._send_telemetry()
|
||||
self._frames_since_sat += 1
|
||||
except Exception as exc:
|
||||
logger.warning("Telemetry loop error: %s", exc)
|
||||
log.warning("telemetry_loop_error", error=str(exc))
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
def _send_telemetry(self) -> None:
|
||||
@@ -437,7 +439,7 @@ class MAVLinkBridge:
|
||||
else:
|
||||
self._conn.named_value_float_send(time=t_boot_ms, name=name, value=float(value))
|
||||
except Exception as exc:
|
||||
logger.debug("Telemetry send error: %s", exc)
|
||||
logger.debug("telemetry_send_error", error=str(exc))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Connection factory
|
||||
@@ -448,8 +450,8 @@ class MAVLinkBridge:
|
||||
if _PYMAVLINK_AVAILABLE:
|
||||
try:
|
||||
conn = _mavutil.mavlink_connection(self.connection_string)
|
||||
logger.info("MAVLink connection opened: %s", self.connection_string)
|
||||
logger.info("mavlink_connection_opened", conn=self.connection_string)
|
||||
return conn
|
||||
except Exception as exc:
|
||||
logger.warning("Cannot open MAVLink connection (%s) — using mock", exc)
|
||||
logger.warning("mavlink_connection_failed", error=str(exc), fallback="mock")
|
||||
return MockMAVConnection()
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
"""Local-disk tile loader (SAT-01/02). Phase 1 home of the existing SatelliteDataManager impl."""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import structlog
|
||||
|
||||
from gps_denied.schemas import GPSPoint
|
||||
from gps_denied.schemas.satellite import TileBounds, TileCoords
|
||||
from gps_denied.utils import mercator
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class SatelliteDataManager:
|
||||
"""Manages satellite tiles from a local pre-loaded directory.
|
||||
@@ -24,8 +26,6 @@ class SatelliteDataManager:
|
||||
downloads and stores tiles before the mission.
|
||||
"""
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tile_dir: str = ".satellite_tiles",
|
||||
@@ -73,8 +73,10 @@ class SatelliteDataManager:
|
||||
sha.update(chunk)
|
||||
actual = sha.hexdigest()
|
||||
if actual != expected:
|
||||
self._logger.warning("Tile integrity failed: %s (exp %s, got %s)",
|
||||
rel_path, expected[:12], actual[:12])
|
||||
logger.warning("tile_integrity_failed",
|
||||
rel_path=rel_path,
|
||||
expected_prefix=expected[:12],
|
||||
actual_prefix=actual[:12])
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@@ -4,11 +4,11 @@ SAT-03: GSD normalization — downsample camera frame to satellite resolution.
|
||||
SAT-04: RANSAC homography → WGS84 position; confidence = inlier_ratio.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import structlog
|
||||
|
||||
from gps_denied.components.satellite_matcher.protocol import IMetricRefinement
|
||||
from gps_denied.core.models import IModelManager
|
||||
@@ -16,7 +16,7 @@ from gps_denied.schemas import GPSPoint
|
||||
from gps_denied.schemas.metric import AlignmentResult, ChunkAlignmentResult, Sim3Transform
|
||||
from gps_denied.schemas.satellite import TileBounds
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class MetricRefinement(IMetricRefinement):
|
||||
|
||||
@@ -19,17 +19,17 @@ hardware; Inertial mode is retained for sprint-1 reversibility.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import structlog
|
||||
|
||||
from gps_denied.components.vio.orbslam_backend import ORBVisualOdometry
|
||||
from gps_denied.components.vio.protocol import ISequentialVisualOdometry
|
||||
from gps_denied.schemas import CameraParameters
|
||||
from gps_denied.schemas.vo import Features, Matches, Motion, RelativePose
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Optional cuVSLAM SDK import (aarch64 Jetson only — x86 dev/CI must still pass)
|
||||
@@ -70,9 +70,9 @@ class CuVSLAMVisualOdometry(ISequentialVisualOdometry):
|
||||
import cuvslam # type: ignore # only available on Jetson
|
||||
self._cuvslam = cuvslam
|
||||
self._init_tracker()
|
||||
logger.info("CuVSLAMVisualOdometry: cuVSLAM SDK loaded (Jetson mode)")
|
||||
logger.info("cuvslam_sdk_loaded", backend="CuVSLAMVisualOdometry", mode="jetson")
|
||||
except ImportError:
|
||||
logger.info("CuVSLAMVisualOdometry: cuVSLAM not available — using ORB fallback (dev/CI mode)")
|
||||
logger.info("cuvslam_sdk_unavailable", backend="CuVSLAMVisualOdometry", fallback="ORB")
|
||||
|
||||
def _init_tracker(self):
|
||||
"""Initialise cuVSLAM tracker in Inertial mode."""
|
||||
@@ -96,9 +96,9 @@ class CuVSLAMVisualOdometry(ISequentialVisualOdometry):
|
||||
gyro_noise=self._imu_params.get("gyro_noise", 0.001),
|
||||
)
|
||||
self._tracker = self._cuvslam.Tracker(rig_params, tracker_params)
|
||||
logger.info("cuVSLAM tracker initialised in Inertial mode")
|
||||
logger.info("cuvslam_tracker_initialised", mode="inertial")
|
||||
except Exception as exc:
|
||||
logger.error("cuVSLAM tracker init failed: %s", exc)
|
||||
logger.error("cuvslam_tracker_init_failed", error=str(exc))
|
||||
self._cuvslam = None
|
||||
|
||||
@property
|
||||
@@ -156,7 +156,7 @@ class CuVSLAMVisualOdometry(ISequentialVisualOdometry):
|
||||
scale_ambiguous=False, # VO-04: cuVSLAM Inertial mode = metric NED
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("cuVSLAM tracking step failed: %s", exc)
|
||||
logger.error("cuvslam_tracking_step_failed", error=str(exc))
|
||||
return None
|
||||
|
||||
|
||||
@@ -205,9 +205,9 @@ class CuVSLAMMonoDepthVisualOdometry(ISequentialVisualOdometry):
|
||||
import cuvslam # type: ignore
|
||||
self._cuvslam = cuvslam
|
||||
self._init_tracker()
|
||||
logger.info("CuVSLAMMonoDepthVisualOdometry: cuVSLAM SDK loaded (Jetson Mono-Depth mode)")
|
||||
logger.info("cuvslam_sdk_loaded", backend="CuVSLAMMonoDepthVisualOdometry", mode="mono_depth")
|
||||
except ImportError:
|
||||
logger.info("CuVSLAMMonoDepthVisualOdometry: cuVSLAM not available — using scaled ORB fallback")
|
||||
logger.info("cuvslam_sdk_unavailable", backend="CuVSLAMMonoDepthVisualOdometry", fallback="scaled_ORB")
|
||||
|
||||
def update_depth_hint(self, depth_hint_m: float) -> None:
|
||||
"""Update barometric altitude used for scale recovery. Call each frame."""
|
||||
@@ -231,11 +231,11 @@ class CuVSLAMMonoDepthVisualOdometry(ISequentialVisualOdometry):
|
||||
tracker_params.use_imu = False
|
||||
tracker_params.odometry_mode = self._cuvslam.OdometryMode.MONO_DEPTH
|
||||
self._tracker = self._cuvslam.Tracker(rig_params, tracker_params)
|
||||
logger.info("cuVSLAM tracker initialised in Mono-Depth mode")
|
||||
logger.info("cuvslam_tracker_initialised", mode="mono_depth")
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"cuVSLAM Mono-Depth tracker init FAILED — falling back to ORB. "
|
||||
"Production Jetson path is DISABLED until this is fixed."
|
||||
"cuvslam_mono_depth_init_failed",
|
||||
note="Production Jetson path is DISABLED until this is fixed.",
|
||||
)
|
||||
self._cuvslam = None
|
||||
|
||||
@@ -277,7 +277,7 @@ class CuVSLAMMonoDepthVisualOdometry(ISequentialVisualOdometry):
|
||||
scale_ambiguous=False,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("cuVSLAM Mono-Depth tracking step failed — frame dropped")
|
||||
logger.exception("cuvslam_mono_depth_tracking_failed", note="frame dropped")
|
||||
return None
|
||||
|
||||
def _compute_via_orb_scaled(
|
||||
|
||||
@@ -14,18 +14,18 @@ optional-import block isolated.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import structlog
|
||||
|
||||
from gps_denied.components.vio.protocol import ISequentialVisualOdometry
|
||||
from gps_denied.core.models import IModelManager
|
||||
from gps_denied.schemas import CameraParameters
|
||||
from gps_denied.schemas.vo import Features, Matches, Motion, RelativePose
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class SequentialVisualOdometry(ISequentialVisualOdometry):
|
||||
@@ -88,7 +88,7 @@ class SequentialVisualOdometry(ISequentialVisualOdometry):
|
||||
pts1, pts2, cameraMatrix=K, method=cv2.RANSAC, prob=0.999, threshold=1.0
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error finding essential matrix: {e}")
|
||||
logger.error("essential_matrix_failed", error=str(e))
|
||||
return None
|
||||
|
||||
if E is None or E.shape != (3, 3):
|
||||
@@ -98,14 +98,14 @@ class SequentialVisualOdometry(ISequentialVisualOdometry):
|
||||
inlier_count = np.sum(inliers_mask)
|
||||
|
||||
if inlier_count < inlier_threshold:
|
||||
logger.warning(f"Insufficient inliers: {inlier_count} < {inlier_threshold}")
|
||||
logger.warning("insufficient_inliers", n_inliers=int(inlier_count), threshold=inlier_threshold)
|
||||
return None
|
||||
|
||||
# Recover pose
|
||||
try:
|
||||
_, R, t, mask = cv2.recoverPose(E, pts1, pts2, cameraMatrix=K, mask=inliers)
|
||||
except Exception as e:
|
||||
logger.error(f"Error recovering pose: {e}")
|
||||
logger.error("recover_pose_failed", error=str(e))
|
||||
return None
|
||||
|
||||
return Motion(
|
||||
@@ -224,7 +224,7 @@ class ORBVisualOdometry(ISequentialVisualOdometry):
|
||||
try:
|
||||
E, inliers = cv2.findEssentialMat(pts1, pts2, cameraMatrix=K, method=cv2.RANSAC, prob=0.999, threshold=1.0)
|
||||
except Exception as exc:
|
||||
logger.warning("ORB findEssentialMat failed: %s", exc)
|
||||
logger.warning("orb_essential_matrix_failed", error=str(exc))
|
||||
return None
|
||||
if E is None or E.shape != (3, 3) or inliers is None:
|
||||
return None
|
||||
@@ -235,7 +235,7 @@ class ORBVisualOdometry(ISequentialVisualOdometry):
|
||||
try:
|
||||
_, R, t, mask = cv2.recoverPose(E, pts1, pts2, cameraMatrix=K, mask=inliers)
|
||||
except Exception as exc:
|
||||
logger.warning("ORB recoverPose failed: %s", exc)
|
||||
logger.warning("orb_recover_pose_failed", error=str(exc))
|
||||
return None
|
||||
return Motion(translation=t.flatten(), rotation=R, inliers=inlier_mask, inlier_count=inlier_count)
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"""Route Chunk Manager (Component F12)."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Dict, List, Optional, Protocol, runtime_checkable
|
||||
|
||||
import structlog
|
||||
|
||||
from gps_denied.core.graph import IFactorGraphOptimizer
|
||||
from gps_denied.schemas.chunk import ChunkHandle, ChunkStatus
|
||||
from gps_denied.schemas.metric import Sim3Transform
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
@@ -67,7 +68,7 @@ class RouteChunkManager(IRouteChunkManager):
|
||||
)
|
||||
self._chunks[flight_id][chunk_id] = handle
|
||||
|
||||
logger.info(f"Created new chunk {chunk_id} starting at frame {start_frame_id}")
|
||||
logger.info("chunk_created", chunk_id=chunk_id, start_frame_id=start_frame_id)
|
||||
return handle
|
||||
|
||||
def get_active_chunk(self, flight_id: str) -> Optional[ChunkHandle]:
|
||||
@@ -123,7 +124,7 @@ class RouteChunkManager(IRouteChunkManager):
|
||||
new_chunk.matching_status = ChunkStatus.MERGED
|
||||
new_chunk.is_active = False
|
||||
|
||||
logger.info(f"Merged chunk {new_chunk_id} into {main_chunk_id}")
|
||||
logger.info("chunks_merged", new_chunk_id=new_chunk_id, main_chunk_id=main_chunk_id)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy as np
|
||||
import structlog
|
||||
|
||||
from gps_denied.schemas import GPSPoint
|
||||
from gps_denied.schemas.eskf import (
|
||||
@@ -18,7 +18,7 @@ from gps_denied.schemas.eskf import (
|
||||
if TYPE_CHECKING:
|
||||
from gps_denied.core.coordinates import CoordinateTransformer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -171,7 +171,7 @@ class ESKF:
|
||||
return
|
||||
dt = imu.timestamp - self._last_timestamp
|
||||
if dt <= 0 or dt > 1.0:
|
||||
logger.debug("Skipping IMU prediction: dt=%.4f", dt)
|
||||
logger.debug("imu_prediction_skipped", dt=round(dt, 4))
|
||||
return
|
||||
|
||||
cfg = self.config
|
||||
@@ -279,8 +279,9 @@ class ESKF:
|
||||
# Mahalanobis outlier gate
|
||||
mahal_sq = float(z @ S_inv @ z)
|
||||
if mahal_sq > self.config.mahalanobis_threshold:
|
||||
logger.warning("Satellite outlier rejected: Mahalanobis² %.1f > %.1f",
|
||||
mahal_sq, self.config.mahalanobis_threshold)
|
||||
logger.warning("satellite_outlier_rejected",
|
||||
mahalanobis_sq=round(mahal_sq, 1),
|
||||
threshold=self.config.mahalanobis_threshold)
|
||||
return z
|
||||
|
||||
K = self._P @ H_sat.T @ S_inv
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Factor Graph Optimizer (Component F10)."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Protocol, runtime_checkable
|
||||
|
||||
import numpy as np
|
||||
import structlog
|
||||
|
||||
try:
|
||||
import gtsam
|
||||
@@ -17,7 +17,7 @@ from gps_denied.schemas.graph import FactorGraphConfig, OptimizationResult, Pose
|
||||
from gps_denied.schemas.metric import Sim3Transform
|
||||
from gps_denied.schemas.vo import RelativePose
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
@@ -142,7 +142,7 @@ class FactorGraphOptimizer(IFactorGraphOptimizer):
|
||||
new_t = gtsam.Point3(float(t[0]), float(t[1]), float(t[2]))
|
||||
state["initial"].insert(key_j, gtsam.Pose3(gtsam.Rot3(), new_t))
|
||||
except Exception as exc:
|
||||
logger.debug("GTSAM add_relative_factor failed: %s", exc)
|
||||
logger.debug("gtsam_add_relative_factor_failed", error=str(exc))
|
||||
|
||||
return True
|
||||
|
||||
@@ -181,7 +181,7 @@ class FactorGraphOptimizer(IFactorGraphOptimizer):
|
||||
if not state["initial"].exists(key):
|
||||
state["initial"].insert(key, prior)
|
||||
except Exception as exc:
|
||||
logger.debug("GTSAM add_absolute_factor failed: %s", exc)
|
||||
logger.debug("gtsam_add_absolute_factor_failed", error=str(exc))
|
||||
|
||||
return True
|
||||
|
||||
@@ -219,7 +219,7 @@ class FactorGraphOptimizer(IFactorGraphOptimizer):
|
||||
state["graph"] = gtsam.NonlinearFactorGraph()
|
||||
state["initial"] = gtsam.Values()
|
||||
except Exception as exc:
|
||||
logger.warning("GTSAM ISAM2 update failed: %s", exc)
|
||||
logger.warning("gtsam_isam2_update_failed", error=str(exc))
|
||||
|
||||
state["dirty"] = False
|
||||
return OptimizationResult(
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
"""Failure Recovery Coordinator (Component F11)."""
|
||||
|
||||
import logging
|
||||
from typing import List, Protocol, runtime_checkable
|
||||
|
||||
import numpy as np
|
||||
import structlog
|
||||
|
||||
from gps_denied.core.chunk_manager import IRouteChunkManager
|
||||
from gps_denied.core.gpr import IGlobalPlaceRecognition
|
||||
from gps_denied.core.metric import IMetricRefinement
|
||||
from gps_denied.schemas.chunk import ChunkStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
@@ -37,7 +37,7 @@ class FailureRecoveryCoordinator(IFailureRecoveryCoordinator):
|
||||
|
||||
def handle_tracking_lost(self, flight_id: str, current_frame_id: int) -> bool:
|
||||
"""Called when F07 fails to find sequential matches."""
|
||||
logger.warning(f"Tracking lost for flight {flight_id} at frame {current_frame_id}")
|
||||
logger.warning("tracking_lost", flight_id=flight_id, current_frame_id=current_frame_id)
|
||||
|
||||
# Create a new active chunk to record new relative frames independently
|
||||
self.chunk_manager.create_new_chunk(flight_id, current_frame_id)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Observability package — structlog spine + boundary log schemas.
|
||||
|
||||
Hot-path logging spine wired by ``configure_logging``; per-frame ``correlation_id``
|
||||
(= frame_id) bound at ``pipeline/orchestrator.py:process_frame``.
|
||||
|
||||
Boundary log schemas (REST, FDR, anchor decisions) defined in ``log_schemas``.
|
||||
"""
|
||||
import structlog
|
||||
|
||||
from gps_denied.obs.log_schemas import (
|
||||
AnchorDecision,
|
||||
AnchorRejectReason,
|
||||
ApiRequestCompleted,
|
||||
MavlinkGpsInputEmitted,
|
||||
SourceLabel,
|
||||
)
|
||||
from gps_denied.obs.logging_config import configure_logging
|
||||
|
||||
get_logger = structlog.get_logger # convenience re-export
|
||||
|
||||
__all__ = [
|
||||
"configure_logging",
|
||||
"get_logger",
|
||||
# Boundary schemas
|
||||
"MavlinkGpsInputEmitted",
|
||||
"ApiRequestCompleted",
|
||||
"AnchorDecision",
|
||||
"SourceLabel",
|
||||
"AnchorRejectReason",
|
||||
]
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Pydantic v2 schemas for boundary log records.
|
||||
|
||||
Boundary = a point where the hot path hands off to (or receives from) an external
|
||||
system: MAVLink wire (AC-4.3), REST API, AnchorVerifier accept/reject (VERIFY-02),
|
||||
FDR JSONL append (AC-NEW-3). Inside the hot path we use
|
||||
``@dataclass(slots=True, frozen=True)`` per ARCH-02 — these Pydantic schemas are
|
||||
NEVER used per-frame inside the pipeline body.
|
||||
|
||||
Usage pattern (Phase 5 / Phase 6 / Phase 3 wiring):
|
||||
|
||||
from gps_denied.obs import log_schemas, get_logger
|
||||
log = get_logger(__name__)
|
||||
|
||||
rec = log_schemas.MavlinkGpsInputEmitted(
|
||||
lat_deg=est.lat_deg, lon_deg=est.lon_deg, alt_m=est.alt_m,
|
||||
fix_type=3, horiz_accuracy_m=est.cov_semi_major_m,
|
||||
source_label=est.source_label, anchor_age_ms=est.anchor_age_ms,
|
||||
cov_semi_major_m=est.cov_semi_major_m,
|
||||
)
|
||||
log.info("mavlink_emit_gps_input", **rec.model_dump(mode="json"))
|
||||
# frame_id is auto-included via structlog merge_contextvars (Plan 02-06).
|
||||
|
||||
``mode="json"`` ensures datetimes / enums / numpy scalars are JSON-safe before the
|
||||
structlog renderer fires. Validation cost on Pydantic v2 / pydantic-core is
|
||||
~10-60 µs per record (RESEARCH.md §5.4 performance note); boundary call rates
|
||||
(≤10 Hz MAVLink, ≤1 Hz API, ≤0.1 Hz failed-tile thumbnails) make this trivial
|
||||
against the 400 ms / frame budget.
|
||||
|
||||
Field validation is intentionally tight (``extra='forbid'``, frozen=True) so adding
|
||||
a producer-side field without updating the schema fails fast.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
# Source-label vocabulary — AC-1.4. Matches the Phase-1 enum
|
||||
# gps_denied.schemas.eskf.ConfidenceTier (kept in stdlib boundary schemas for
|
||||
# legacy DB rows); new code uses this Literal.
|
||||
SourceLabel = Literal["satellite_anchored", "vo_extrapolated", "dead_reckoned"]
|
||||
|
||||
# Reject-reason vocabulary — REQUIREMENTS.md VERIFY-02. Stable contract; Phase 3
|
||||
# AnchorVerifier emits exactly these strings.
|
||||
AnchorRejectReason = Literal[
|
||||
"ok",
|
||||
"too_few_inliers",
|
||||
"mre_above_threshold",
|
||||
"degenerate_homography",
|
||||
"freshness_expired",
|
||||
]
|
||||
|
||||
|
||||
class _BoundaryLogRecord(BaseModel):
|
||||
"""Common base. Tighten the contract:
|
||||
- ``extra='forbid'`` — producer-side typo or schema drift fails validation.
|
||||
- ``frozen=True`` — records are facts, not state. No mutation after build.
|
||||
"""
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
|
||||
class MavlinkGpsInputEmitted(_BoundaryLogRecord):
|
||||
"""Logged once per GPS_INPUT MAVLink message emitted on the wire (AC-4.3).
|
||||
|
||||
AC-1.4 categorical source_label + AC-1.3 anchor_age_ms + AC-NEW-8 cov_semi_major_m
|
||||
are all required fields — the producer must compute them. Phase 5 (MAVOUT-01) wires.
|
||||
"""
|
||||
lat_deg: float
|
||||
lon_deg: float
|
||||
alt_m: float
|
||||
fix_type: int = Field(ge=0, le=6) # MAVLink GPS fix_type: 0..6
|
||||
horiz_accuracy_m: float = Field(ge=0)
|
||||
source_label: SourceLabel # AC-1.4
|
||||
anchor_age_ms: int = Field(ge=0) # AC-1.3
|
||||
cov_semi_major_m: float = Field(ge=0) # AC-1.4 (95% covariance)
|
||||
|
||||
|
||||
class ApiRequestCompleted(_BoundaryLogRecord):
|
||||
"""Logged at the end of every FastAPI request. Phase 6 middleware consumer."""
|
||||
path: str
|
||||
method: Literal["GET", "POST", "PUT", "DELETE", "PATCH"]
|
||||
status_code: int = Field(ge=100, lt=600)
|
||||
duration_ms: float = Field(ge=0)
|
||||
|
||||
|
||||
class AnchorDecision(_BoundaryLogRecord):
|
||||
"""Logged on every AnchorVerifier accept/reject (VERIFY-02). Phase 3 consumer."""
|
||||
decision: Literal["accept", "reject"]
|
||||
reason: AnchorRejectReason
|
||||
n_inliers: int = Field(ge=0)
|
||||
mre_px: float = Field(ge=0)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SourceLabel",
|
||||
"AnchorRejectReason",
|
||||
"MavlinkGpsInputEmitted",
|
||||
"ApiRequestCompleted",
|
||||
"AnchorDecision",
|
||||
]
|
||||
@@ -0,0 +1,67 @@
|
||||
"""structlog configuration. Call ``configure_logging`` once at app boot.
|
||||
|
||||
Per Phase 2 / OBS-01: hot path uses structlog with ``correlation_id`` (= frame_id)
|
||||
bound at frame entry. Non-hot-path code keeps stdlib ``logging`` until Phase 6
|
||||
(api/, db/, scripts/, composition.py at startup, core/models.py engine load).
|
||||
The stdlib bridge below lets stdlib records flow through the same renderer.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Literal
|
||||
|
||||
import orjson
|
||||
import structlog
|
||||
|
||||
_Env = Literal["jetson", "x86_dev", "ci", "sitl"]
|
||||
|
||||
_configured = False
|
||||
|
||||
|
||||
def configure_logging(env: _Env, level: int = logging.INFO) -> None:
|
||||
"""Configure structlog ONCE at app boot. Idempotent — repeat calls no-op.
|
||||
|
||||
Args:
|
||||
env: Deployment environment. Controls renderer:
|
||||
- ``x86_dev`` -> pretty console renderer
|
||||
- ``jetson|ci|sitl`` -> JSON renderer (orjson) + bytes logger factory
|
||||
level: Stdlib log level threshold. ``filtering_bound_logger`` short-circuits
|
||||
sub-level calls in ~50-100 ns. Keep at INFO in production.
|
||||
"""
|
||||
global _configured
|
||||
if _configured:
|
||||
return
|
||||
|
||||
shared_processors: list = [
|
||||
# MUST be first — pulls bound frame_id into every record.
|
||||
structlog.contextvars.merge_contextvars,
|
||||
structlog.processors.add_log_level,
|
||||
structlog.processors.TimeStamper(fmt="iso", utc=True),
|
||||
structlog.processors.format_exc_info,
|
||||
]
|
||||
|
||||
if env == "x86_dev":
|
||||
processors = [*shared_processors, structlog.dev.ConsoleRenderer()]
|
||||
logger_factory = structlog.PrintLoggerFactory()
|
||||
else:
|
||||
# jetson | ci | sitl — JSON to bytes via orjson; fastest path on hot loop.
|
||||
processors = [
|
||||
*shared_processors,
|
||||
structlog.processors.JSONRenderer(serializer=orjson.dumps),
|
||||
]
|
||||
logger_factory = structlog.BytesLoggerFactory()
|
||||
|
||||
structlog.configure(
|
||||
processors=processors,
|
||||
wrapper_class=structlog.make_filtering_bound_logger(level),
|
||||
logger_factory=logger_factory,
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
|
||||
# Bridge stdlib logging: api/, db/, scripts/, composition.py, core/models.py
|
||||
# (Phase 6 ports these to structlog directly). Until then, their records share
|
||||
# the same level threshold; format passthrough is via "%(message)s" because the
|
||||
# structlog renderer above is the actual output sink.
|
||||
logging.basicConfig(level=level, format="%(message)s")
|
||||
|
||||
_configured = True
|
||||
@@ -17,6 +17,8 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from gps_denied.obs import configure_logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -49,6 +51,10 @@ def build_pipeline(
|
||||
FlightProcessor
|
||||
Fully wired processor with all components attached.
|
||||
"""
|
||||
# OBS-01: configure structlog idempotently so tests / scripts calling
|
||||
# build_pipeline directly (without app.py lifespan) still get logging configured.
|
||||
configure_logging(env=env)
|
||||
|
||||
# Lazy imports to avoid circular import chains at module load time.
|
||||
from gps_denied.components.gpr.faiss_gpr import GlobalPlaceRecognition
|
||||
from gps_denied.components.mavlink_io.pymavlink_bridge import MAVLinkBridge
|
||||
|
||||
@@ -7,12 +7,13 @@ State Machine: NORMAL → LOST → RECOVERY → NORMAL.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import structlog
|
||||
from structlog.contextvars import bind_contextvars, clear_contextvars
|
||||
|
||||
from gps_denied.core.eskf import ESKF
|
||||
from gps_denied.pipeline.image_input import ImageInputPipeline
|
||||
@@ -36,7 +37,7 @@ from gps_denied.schemas.flight import (
|
||||
Waypoint,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -178,6 +179,23 @@ class FlightProcessor:
|
||||
PIPE-05: ImageRotationManager initialised on first frame
|
||||
PIPE-07: ESKF confidence → MAVLink fix_type via bridge.update_state
|
||||
"""
|
||||
# OBS-01: bind correlation_id=frame_id for the duration of this frame.
|
||||
# All hot-path log calls (in this file + the 9 component files) auto-include
|
||||
# frame_id and flight_id via structlog.contextvars.merge_contextvars.
|
||||
clear_contextvars()
|
||||
bind_contextvars(correlation_id=frame_id, flight_id=flight_id)
|
||||
try:
|
||||
return await self._process_frame_inner(flight_id, frame_id, image)
|
||||
finally:
|
||||
clear_contextvars()
|
||||
|
||||
async def _process_frame_inner(
|
||||
self,
|
||||
flight_id: str,
|
||||
frame_id: int,
|
||||
image,
|
||||
):
|
||||
"""Inner frame processing — called with correlation_id already bound."""
|
||||
result = FrameResult(frame_id)
|
||||
state = self._flight_states.get(flight_id, TrackingState.NORMAL)
|
||||
eskf = self._eskf.get(flight_id)
|
||||
@@ -187,6 +205,8 @@ class FlightProcessor:
|
||||
resolution_width=640, resolution_height=480,
|
||||
)
|
||||
|
||||
logger.info("frame_started", state=state.value)
|
||||
|
||||
# ---- PIPE-05: Initialise heading tracking on first frame ----
|
||||
if self._rotation and frame_id == 0:
|
||||
self._rotation.requires_rotation_sweep(flight_id) # seeds HeadingHistory
|
||||
@@ -214,7 +234,7 @@ class FlightProcessor:
|
||||
dt_vo = max(0.01, now - (eskf.last_timestamp or now))
|
||||
eskf.update_vo(rel_pose.translation, dt_vo)
|
||||
except Exception as exc:
|
||||
logger.warning("VO failed for frame %d: %s", frame_id, exc)
|
||||
logger.warning("vo_failed", error=str(exc))
|
||||
|
||||
# Store current image for next frame
|
||||
self._prev_images[flight_id] = image
|
||||
@@ -229,7 +249,7 @@ class FlightProcessor:
|
||||
if state == TrackingState.NORMAL:
|
||||
if not vo_ok and frame_id > 0:
|
||||
state = TrackingState.LOST
|
||||
logger.info("Flight %s → LOST at frame %d", flight_id, frame_id)
|
||||
logger.info("flight_state_change", new_state="LOST")
|
||||
if self._recovery:
|
||||
self._recovery.handle_tracking_lost(flight_id, frame_id)
|
||||
|
||||
@@ -249,7 +269,7 @@ class FlightProcessor:
|
||||
result.alignment_success = True
|
||||
# PIPE-04: Reset failure count on successful recovery
|
||||
self._failure_counts[flight_id] = 0
|
||||
logger.info("Flight %s recovered → NORMAL at frame %d", flight_id, frame_id)
|
||||
logger.info("flight_state_change", new_state="NORMAL", source="recovery")
|
||||
|
||||
# ---- 3. Satellite position fix (PIPE-01/02) ----
|
||||
if state == TrackingState.NORMAL and self._metric:
|
||||
@@ -274,7 +294,7 @@ class FlightProcessor:
|
||||
if tile_result:
|
||||
sat_tile, tile_bounds = tile_result
|
||||
except Exception as exc:
|
||||
logger.debug("Satellite tile fetch failed: %s", exc)
|
||||
logger.debug("satellite_tile_fetch_failed", error=str(exc))
|
||||
|
||||
# Fallback: GPR candidate tile (mock image, real bounds)
|
||||
if sat_tile is None and self._gpr:
|
||||
@@ -284,7 +304,7 @@ class FlightProcessor:
|
||||
sat_tile = np.zeros((256, 256, 3), dtype=np.uint8)
|
||||
tile_bounds = candidates[0].bounds
|
||||
except Exception as exc:
|
||||
logger.debug("GPR tile fallback failed: %s", exc)
|
||||
logger.debug("gpr_tile_fallback_failed", error=str(exc))
|
||||
|
||||
if sat_tile is not None and tile_bounds is not None:
|
||||
try:
|
||||
@@ -310,16 +330,17 @@ class FlightProcessor:
|
||||
noise_m = 5.0 + 15.0 * (1.0 - float(align.confidence))
|
||||
eskf.update_satellite(pos_enu, noise_m)
|
||||
except Exception as exc:
|
||||
logger.debug("ESKF satellite update failed: %s", exc)
|
||||
logger.debug("eskf_satellite_update_failed", error=str(exc))
|
||||
except Exception as exc:
|
||||
logger.warning("Metric alignment failed at frame %d: %s", frame_id, exc)
|
||||
logger.warning("metric_alignment_failed", error=str(exc))
|
||||
|
||||
# ---- 4. Graph optimization (incremental) ----
|
||||
if self._graph:
|
||||
opt_result = self._graph.optimize(flight_id, iterations=5)
|
||||
logger.debug(
|
||||
"Optimization: converged=%s, error=%.4f",
|
||||
opt_result.converged, opt_result.final_error,
|
||||
"graph_optimized",
|
||||
converged=opt_result.converged,
|
||||
error=round(opt_result.final_error, 4),
|
||||
)
|
||||
|
||||
# ---- PIPE-07: Push ESKF state → MAVLink GPS_INPUT ----
|
||||
@@ -329,11 +350,12 @@ class FlightProcessor:
|
||||
alt = self._altitudes.get(flight_id, 100.0)
|
||||
self._mavlink.update_state(eskf_state, altitude_m=alt)
|
||||
except Exception as exc:
|
||||
logger.debug("MAVLink state push failed: %s", exc)
|
||||
logger.debug("mavlink_state_push_failed", error=str(exc))
|
||||
|
||||
# ---- 5. Publish via SSE ----
|
||||
result.tracking_state = state
|
||||
self._flight_states[flight_id] = state
|
||||
logger.info("frame_complete", tracking_state=state.value, alignment=result.alignment_success)
|
||||
await self._publish_frame_result(flight_id, result)
|
||||
return result
|
||||
|
||||
@@ -393,7 +415,7 @@ class FlightProcessor:
|
||||
try:
|
||||
asyncio.create_task(self._mavlink.start(req.start_gps))
|
||||
except Exception as exc:
|
||||
logger.warning("MAVLink bridge start failed: %s", exc)
|
||||
logger.warning("mavlink_bridge_start_failed", error=str(exc))
|
||||
|
||||
return FlightResponse(
|
||||
flight_id=flight.id,
|
||||
@@ -532,9 +554,9 @@ class FlightProcessor:
|
||||
alt = self._altitudes.get(flight_id, 100.0)
|
||||
eskf.update_satellite(np.array([e, n, alt]), noise_meters=500.0)
|
||||
self._failure_counts[flight_id] = 0
|
||||
logger.info("User fix applied for %s: %s", flight_id, req.satellite_gps)
|
||||
logger.info("user_fix_applied", flight_id=flight_id, gps=str(req.satellite_gps))
|
||||
except Exception as exc:
|
||||
logger.warning("User fix ESKF injection failed: %s", exc)
|
||||
logger.warning("user_fix_eskf_failed", error=str(exc))
|
||||
|
||||
return UserFixResponse(
|
||||
accepted=True, processing_resumed=True, message="Fix applied."
|
||||
@@ -580,7 +602,7 @@ class FlightProcessor:
|
||||
quaternion=quat,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("pixel_to_gps failed: %s", exc)
|
||||
logger.debug("pixel_to_gps_failed", error=str(exc))
|
||||
|
||||
# Fallback: return ESKF position projected to ground (no pixel shift)
|
||||
if gps is None and eskf:
|
||||
|
||||
@@ -9,6 +9,96 @@ from gps_denied.core.coordinates import CoordinateTransformer
|
||||
from gps_denied.core.models import ModelManager
|
||||
from gps_denied.schemas import CameraParameters, GPSPoint
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Phase 2 / TEST-03: AC traceability plugin
|
||||
#
|
||||
# Registers categorical markers (defensive — pyproject.toml is primary), validates
|
||||
# @pytest.mark.ac() arguments against the canonical AC-ID regex at collection time,
|
||||
# and (when --ac-dump=<path> is supplied) writes a {ac_id: [test_nodeid, ...]} JSON
|
||||
# at session end for `scripts/gen_ac_traceability.py` to consume.
|
||||
#
|
||||
# See RESEARCH.md §2.1 for the canonical implementation and rationale.
|
||||
# ---------------------------------------------------------------
|
||||
import json
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
_AC_ID_RE = re.compile(r"^AC-(?:\d+\.\d+[a-z]?|NEW-\d+)$")
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
"""Defensive marker registration. Primary registration lives in pyproject.toml,
|
||||
but doing it here too means a future maintainer who drops the pyproject markers
|
||||
list does not silently break --strict-markers."""
|
||||
for line in (
|
||||
"unit: pure-math or single-class test; no I/O",
|
||||
"integration: cross-subsystem test; in-memory SQLite / ASGI / full wiring",
|
||||
"blackbox: validates external contract without a live producer",
|
||||
"sitl: requires ARDUPILOT_SITL_HOST — nightly only",
|
||||
"e2e: full-pipeline run against a real dataset — nightly only",
|
||||
"ac(ac_id): link test to one or more Acceptance Criteria (e.g. AC-1.1, AC-NEW-3)",
|
||||
):
|
||||
config.addinivalue_line("markers", line)
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
parser.addoption(
|
||||
"--ac-dump",
|
||||
action="store",
|
||||
default=None,
|
||||
help="Path to write the {ac_id: [test_nodeid, ...]} JSON at session end. "
|
||||
"Consumed by scripts/gen_ac_traceability.py.",
|
||||
)
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config, items):
|
||||
"""Validate @pytest.mark.ac(...) arguments against the canonical AC-ID regex.
|
||||
|
||||
Fail collection (rather than emit a runtime error) so AC-ID typos surface immediately.
|
||||
The traceability script's --check mode catches forward orphans (AC without test);
|
||||
this hook catches backward orphans (test references non-existent AC) by enforcing
|
||||
the syntactic AC-ID shape. Semantic existence (AC ID is declared in the AC doc) is
|
||||
enforced by the script in Plan 02-04.
|
||||
"""
|
||||
errors = []
|
||||
for item in items:
|
||||
for mark in item.iter_markers(name="ac"):
|
||||
if not mark.args:
|
||||
errors.append(
|
||||
f"{item.nodeid}: @pytest.mark.ac() requires at least one AC ID arg"
|
||||
)
|
||||
continue
|
||||
for arg in mark.args:
|
||||
if not isinstance(arg, str) or not _AC_ID_RE.match(arg):
|
||||
errors.append(
|
||||
f"{item.nodeid}: @pytest.mark.ac({arg!r}) — must match "
|
||||
f"AC-X.Y or AC-NEW-N (e.g. 'AC-1.1', 'AC-NEW-3')"
|
||||
)
|
||||
if errors:
|
||||
raise pytest.UsageError(
|
||||
"AC marker validation failed:\n " + "\n ".join(errors)
|
||||
)
|
||||
|
||||
|
||||
def pytest_sessionfinish(session, exitstatus):
|
||||
"""Dump {ac_id: [test_nodeid, ...]} to --ac-dump path if supplied.
|
||||
|
||||
Runs even when pytest is invoked with --collect-only (session.items is populated
|
||||
before tests execute), so scripts/gen_ac_traceability.py can dump in ~seconds on a
|
||||
full suite.
|
||||
"""
|
||||
dump_path = session.config.getoption("--ac-dump", default=None)
|
||||
if not dump_path:
|
||||
return
|
||||
mapping: dict[str, list[str]] = defaultdict(list)
|
||||
for item in session.items:
|
||||
for mark in item.iter_markers(name="ac"):
|
||||
for ac_id in mark.args:
|
||||
mapping[ac_id].append(item.nodeid)
|
||||
Path(dump_path).write_text(json.dumps(dict(sorted(mapping.items())), indent=2))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Common constants
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.unit]
|
||||
|
||||
from gps_denied.testing.coord import ecef_to_wgs84, euler_to_quaternion
|
||||
|
||||
# --- ECEF → WGS84 ---
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.unit]
|
||||
|
||||
from gps_denied.testing.datasets.base import (
|
||||
DatasetAdapter,
|
||||
DatasetCapabilities,
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.unit]
|
||||
|
||||
from gps_denied.testing.download import (
|
||||
DATASET_REGISTRY,
|
||||
DatasetSpec,
|
||||
|
||||
@@ -13,6 +13,8 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.e2e]
|
||||
|
||||
from gps_denied.testing.datasets.euroc import EuRoCAdapter
|
||||
from gps_denied.testing.harness import E2EHarness
|
||||
from gps_denied.testing.metrics import absolute_trajectory_error
|
||||
|
||||
@@ -4,6 +4,8 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.unit]
|
||||
|
||||
from gps_denied.testing.datasets.base import (
|
||||
DatasetNotAvailableError,
|
||||
PlatformClass,
|
||||
|
||||
@@ -17,6 +17,8 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.e2e]
|
||||
|
||||
from gps_denied.testing.datasets.euroc import EuRoCAdapter
|
||||
from gps_denied.testing.harness import E2EHarness
|
||||
from gps_denied.testing.metrics import absolute_trajectory_error
|
||||
|
||||
@@ -20,6 +20,8 @@ import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.e2e]
|
||||
|
||||
from gps_denied.core.vo import ORBVisualOdometry
|
||||
from gps_denied.schemas import CameraParameters
|
||||
from gps_denied.testing.datasets.euroc import EuRoCAdapter
|
||||
|
||||
@@ -7,6 +7,8 @@ Correctness of VO on synthetic is out of scope — that's unit-test territory.
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.integration]
|
||||
|
||||
from gps_denied.testing.datasets.synthetic import SyntheticAdapter
|
||||
from gps_denied.testing.harness import E2EHarness, HarnessResult
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.e2e]
|
||||
|
||||
from gps_denied.testing.datasets.mars_lvig import MARSLVIGAdapter
|
||||
from gps_denied.testing.harness import E2EHarness
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.unit]
|
||||
|
||||
from gps_denied.testing.datasets.base import (
|
||||
DatasetNotAvailableError,
|
||||
PlatformClass,
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.unit]
|
||||
|
||||
from gps_denied.testing.metrics import (
|
||||
absolute_trajectory_error,
|
||||
relative_pose_error,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"""SyntheticAdapter produces a deterministic straight-line trajectory."""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from gps_denied.testing.datasets.base import PlatformClass
|
||||
|
||||
pytestmark = [pytest.mark.unit]
|
||||
from gps_denied.testing.datasets.synthetic import SyntheticAdapter
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.e2e]
|
||||
|
||||
from gps_denied.testing.datasets.vpair import VPAIRAdapter
|
||||
from gps_denied.testing.harness import E2EHarness
|
||||
from gps_denied.testing.metrics import absolute_trajectory_error
|
||||
|
||||
@@ -10,6 +10,8 @@ from pathlib import Path
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.unit]
|
||||
|
||||
from gps_denied.testing.datasets.base import (
|
||||
DatasetNotAvailableError,
|
||||
PlatformClass,
|
||||
|
||||
@@ -13,6 +13,8 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.integration]
|
||||
|
||||
from gps_denied.core.chunk_manager import RouteChunkManager
|
||||
from gps_denied.core.gpr import GlobalPlaceRecognition
|
||||
from gps_denied.core.graph import FactorGraphOptimizer
|
||||
@@ -59,6 +61,8 @@ def _random_frame(h=200, w=200):
|
||||
# ---------------------------------------------------------------
|
||||
# AC-1: Normal flight — 20 consecutive frames
|
||||
# ---------------------------------------------------------------
|
||||
@pytest.mark.ac("AC-1.1")
|
||||
@pytest.mark.ac("AC-4.4")
|
||||
@pytest.mark.asyncio
|
||||
async def test_ac1_normal_flight(wired_processor):
|
||||
"""Twenty frames processed without crash; SSE events emitted for each."""
|
||||
@@ -74,6 +78,8 @@ async def test_ac1_normal_flight(wired_processor):
|
||||
# ---------------------------------------------------------------
|
||||
# AC-2: Tracking loss → recovery cycle
|
||||
# ---------------------------------------------------------------
|
||||
@pytest.mark.ac("AC-2.1a")
|
||||
@pytest.mark.ac("AC-3.4")
|
||||
@pytest.mark.asyncio
|
||||
async def test_ac2_tracking_loss_and_recovery(wired_processor, monkeypatch):
|
||||
"""
|
||||
@@ -120,6 +126,7 @@ async def test_ac2_tracking_loss_and_recovery(wired_processor, monkeypatch):
|
||||
# ---------------------------------------------------------------
|
||||
# AC-3: Performance — < 5 s per frame
|
||||
# ---------------------------------------------------------------
|
||||
@pytest.mark.ac("AC-4.1")
|
||||
@pytest.mark.asyncio
|
||||
async def test_ac3_performance_per_frame(wired_processor):
|
||||
"""Each process_frame call must complete in < 5 seconds (mock pipeline)."""
|
||||
@@ -142,6 +149,8 @@ async def test_ac3_performance_per_frame(wired_processor):
|
||||
# ---------------------------------------------------------------
|
||||
# AC-4: User anchor fix
|
||||
# ---------------------------------------------------------------
|
||||
@pytest.mark.ac("AC-4.4")
|
||||
@pytest.mark.ac("AC-1.4")
|
||||
@pytest.mark.asyncio
|
||||
async def test_ac4_user_anchor_fix(wired_processor):
|
||||
"""
|
||||
@@ -183,6 +192,8 @@ async def test_ac4_user_anchor_fix(wired_processor):
|
||||
# ---------------------------------------------------------------
|
||||
# AC-5: Sustained throughput — 50 frames
|
||||
# ---------------------------------------------------------------
|
||||
@pytest.mark.ac("AC-1.1")
|
||||
@pytest.mark.ac("AC-4.4")
|
||||
@pytest.mark.asyncio
|
||||
async def test_ac5_sustained_throughput(wired_processor):
|
||||
"""Process 50 frames back-to-back; no crashes, total < 30 seconds."""
|
||||
@@ -199,6 +210,7 @@ async def test_ac5_sustained_throughput(wired_processor):
|
||||
# ---------------------------------------------------------------
|
||||
# AC-6: Factor graph optimization converges
|
||||
# ---------------------------------------------------------------
|
||||
@pytest.mark.ac("AC-3.3")
|
||||
@pytest.mark.asyncio
|
||||
async def test_ac6_graph_optimization_convergence(wired_processor):
|
||||
"""After N frames the graph should report convergence."""
|
||||
|
||||
@@ -22,6 +22,8 @@ import time
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.integration]
|
||||
|
||||
from gps_denied.core.benchmark import (
|
||||
AccuracyBenchmark,
|
||||
BenchmarkResult,
|
||||
@@ -160,6 +162,7 @@ def test_benchmark_summary_non_empty():
|
||||
# AC-PERF-3: Latency < 400ms
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@pytest.mark.ac("AC-4.1")
|
||||
def test_per_frame_latency_under_400ms():
|
||||
"""AC-PERF-3: p95 per-frame latency < 400ms on synthetic trajectory."""
|
||||
result = _run_benchmark(num_frames=20)
|
||||
@@ -180,6 +183,7 @@ def test_median_error_with_sat_corrections():
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.ac("AC-1.1")
|
||||
def test_pct_within_50m_with_sat_corrections():
|
||||
"""AC-PERF-1: ≥80% frames within 50m when satellite corrections are active."""
|
||||
result = _run_benchmark(num_frames=40, with_sat=True)
|
||||
@@ -189,6 +193,7 @@ def test_pct_within_50m_with_sat_corrections():
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.ac("AC-1.2")
|
||||
def test_pct_within_20m_with_sat_corrections():
|
||||
"""AC-PERF-2: ≥60% frames within 20m when satellite corrections are active."""
|
||||
result = _run_benchmark(num_frames=40, with_sat=True)
|
||||
@@ -237,6 +242,7 @@ def test_waypoint_steering_changes_direction():
|
||||
# AC-PERF-4: VO drift over 1 km straight segment
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@pytest.mark.ac("AC-1.3")
|
||||
def test_vo_drift_under_100m_over_1km():
|
||||
"""AC-PERF-4: VO drift (no sat correction) over 1 km < 100 m."""
|
||||
bench = AccuracyBenchmark()
|
||||
@@ -250,6 +256,7 @@ def test_vo_drift_under_100m_over_1km():
|
||||
# AC-PERF-6: Covariance shrinks after satellite update
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@pytest.mark.ac("AC-2.2")
|
||||
def test_covariance_shrinks_after_satellite_update():
|
||||
"""AC-PERF-6: ESKF position covariance trace decreases after satellite correction."""
|
||||
from gps_denied.core.eskf import ESKF
|
||||
@@ -272,6 +279,7 @@ def test_covariance_shrinks_after_satellite_update():
|
||||
# AC-PERF-5: Confidence tier transitions
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@pytest.mark.ac("AC-2.1a")
|
||||
def test_confidence_high_after_fresh_satellite():
|
||||
"""AC-PERF-5: HIGH confidence when satellite correction is recent + covariance small."""
|
||||
from gps_denied.core.eskf import ESKF
|
||||
@@ -321,6 +329,8 @@ def test_confidence_failed_after_3_consecutive():
|
||||
# passes_acceptance_criteria integration
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@pytest.mark.ac("AC-1.1")
|
||||
@pytest.mark.ac("AC-1.2")
|
||||
def test_passes_acceptance_criteria_full_pass():
|
||||
"""passes_acceptance_criteria returns (True, all-True) for ideal data."""
|
||||
result = BenchmarkResult(
|
||||
@@ -334,6 +344,7 @@ def test_passes_acceptance_criteria_full_pass():
|
||||
assert all(checks.values())
|
||||
|
||||
|
||||
@pytest.mark.ac("AC-4.1")
|
||||
def test_passes_acceptance_criteria_latency_fail():
|
||||
"""passes_acceptance_criteria fails when latency exceeds 400ms."""
|
||||
result = BenchmarkResult(
|
||||
@@ -347,6 +358,7 @@ def test_passes_acceptance_criteria_latency_fail():
|
||||
assert checks["AC-PERF-3: p95 latency < 400ms"] is False
|
||||
|
||||
|
||||
@pytest.mark.ac("AC-1.1")
|
||||
def test_passes_acceptance_criteria_accuracy_fail():
|
||||
"""passes_acceptance_criteria fails when less than 80% within 50m."""
|
||||
result = BenchmarkResult(
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.integration]
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.unit]
|
||||
|
||||
from gps_denied.core.chunk_manager import RouteChunkManager
|
||||
from gps_denied.core.graph import FactorGraphOptimizer
|
||||
from gps_denied.schemas.chunk import ChunkStatus
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.unit]
|
||||
|
||||
from gps_denied.core.coordinates import (
|
||||
CoordinateTransformer,
|
||||
OriginNotSetError,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"""Tests for the database layer — CRUD, cascade, transactions."""
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.integration]
|
||||
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.unit]
|
||||
|
||||
from gps_denied.core.coordinates import CoordinateTransformer
|
||||
from gps_denied.core.eskf import ESKF
|
||||
from gps_denied.schemas import GPSPoint
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.unit]
|
||||
|
||||
from gps_denied.core.gpr import GlobalPlaceRecognition
|
||||
from gps_denied.core.models import ModelManager
|
||||
from gps_denied.schemas.gpr import TileCandidate
|
||||
|
||||
@@ -17,6 +17,8 @@ import time
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.blackbox, pytest.mark.ac("AC-4.3")]
|
||||
|
||||
from gps_denied.core.mavlink import _confidence_to_fix_type, _eskf_to_gps_input
|
||||
from gps_denied.schemas import GPSPoint
|
||||
from gps_denied.schemas.eskf import ConfidenceTier, ESKFState
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.unit]
|
||||
|
||||
from gps_denied.core.graph import FactorGraphOptimizer
|
||||
from gps_denied.schemas import GPSPoint
|
||||
from gps_denied.schemas.graph import FactorGraphConfig
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"""Tests for the health endpoint."""
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.integration]
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from gps_denied.app import app
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Unit tests for boundary-log Pydantic schemas in src/gps_denied/obs/log_schemas.py.
|
||||
|
||||
Phase 2 / OBS-01 + AC-02. Producer wiring lives in Phase 3 (AnchorDecision),
|
||||
Phase 5 (MavlinkGpsInputEmitted), and Phase 6 (ApiRequestCompleted); this file
|
||||
asserts the schema CONTRACT today.
|
||||
"""
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from gps_denied.obs import (
|
||||
AnchorDecision,
|
||||
ApiRequestCompleted,
|
||||
MavlinkGpsInputEmitted,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.unit]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# MavlinkGpsInputEmitted — AC-4.3 (GPS_INPUT contract), AC-1.4 (source_label vocab)
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
VALID_GPS_INPUT = dict(
|
||||
lat_deg=49.0, lon_deg=32.0, alt_m=400.0,
|
||||
fix_type=3, horiz_accuracy_m=12.0,
|
||||
source_label="satellite_anchored",
|
||||
anchor_age_ms=120, cov_semi_major_m=11.5,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.ac("AC-4.3")
|
||||
@pytest.mark.ac("AC-1.4")
|
||||
def test_mavlink_gps_input_round_trip():
|
||||
"""Construction + model_dump(mode='json') round-trip is the producer pattern."""
|
||||
rec = MavlinkGpsInputEmitted(**VALID_GPS_INPUT)
|
||||
d = rec.model_dump(mode="json")
|
||||
assert d["lat_deg"] == 49.0
|
||||
assert d["fix_type"] == 3
|
||||
assert d["source_label"] == "satellite_anchored"
|
||||
|
||||
|
||||
@pytest.mark.ac("AC-1.4")
|
||||
@pytest.mark.parametrize("label", [
|
||||
"satellite_anchored", "vo_extrapolated", "dead_reckoned",
|
||||
])
|
||||
def test_mavlink_source_label_accepts_canonical_vocab(label):
|
||||
"""AC-1.4 — exactly three categorical labels are valid."""
|
||||
rec = MavlinkGpsInputEmitted(**{**VALID_GPS_INPUT, "source_label": label})
|
||||
assert rec.source_label == label
|
||||
|
||||
|
||||
@pytest.mark.ac("AC-1.4")
|
||||
def test_mavlink_source_label_rejects_unknown():
|
||||
"""AC-1.4 — non-canonical source_label fails validation."""
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
MavlinkGpsInputEmitted(**{**VALID_GPS_INPUT, "source_label": "GPS"})
|
||||
assert "source_label" in str(exc.value)
|
||||
|
||||
|
||||
@pytest.mark.ac("AC-4.3")
|
||||
@pytest.mark.parametrize("fix_type", [-1, 7, 100])
|
||||
def test_mavlink_fix_type_bounds(fix_type):
|
||||
"""MAVLink GPS fix_type is bounded 0..6."""
|
||||
with pytest.raises(ValidationError):
|
||||
MavlinkGpsInputEmitted(**{**VALID_GPS_INPUT, "fix_type": fix_type})
|
||||
|
||||
|
||||
@pytest.mark.ac("AC-4.3")
|
||||
def test_mavlink_extra_field_rejected():
|
||||
"""extra='forbid' — producer typo / drift fails fast."""
|
||||
with pytest.raises(ValidationError):
|
||||
MavlinkGpsInputEmitted(**VALID_GPS_INPUT, unexpected_field=42)
|
||||
|
||||
|
||||
@pytest.mark.ac("AC-4.3")
|
||||
def test_mavlink_record_is_frozen():
|
||||
"""Records are facts; mutation after construction is forbidden."""
|
||||
rec = MavlinkGpsInputEmitted(**VALID_GPS_INPUT)
|
||||
with pytest.raises(ValidationError):
|
||||
rec.fix_type = 1 # type: ignore[misc]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# ApiRequestCompleted — Phase 6 consumer (REST middleware)
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@pytest.mark.ac("AC-6.3")
|
||||
def test_api_request_round_trip():
|
||||
rec = ApiRequestCompleted(
|
||||
path="/flights",
|
||||
method="POST",
|
||||
status_code=201,
|
||||
duration_ms=12.5,
|
||||
)
|
||||
d = rec.model_dump(mode="json")
|
||||
assert d["method"] == "POST"
|
||||
assert d["status_code"] == 201
|
||||
|
||||
|
||||
def test_api_request_rejects_unknown_method():
|
||||
with pytest.raises(ValidationError):
|
||||
ApiRequestCompleted(path="/x", method="OPTIONS", status_code=200, duration_ms=1.0)
|
||||
|
||||
|
||||
def test_api_request_status_bounds():
|
||||
with pytest.raises(ValidationError):
|
||||
ApiRequestCompleted(path="/x", method="GET", status_code=99, duration_ms=1.0)
|
||||
with pytest.raises(ValidationError):
|
||||
ApiRequestCompleted(path="/x", method="GET", status_code=600, duration_ms=1.0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# AnchorDecision — VERIFY-02 (Phase 3 consumer)
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@pytest.mark.ac("AC-2.1b")
|
||||
def test_anchor_decision_accept_round_trip():
|
||||
rec = AnchorDecision(decision="accept", reason="ok", n_inliers=42, mre_px=0.8)
|
||||
d = rec.model_dump(mode="json")
|
||||
assert d["decision"] == "accept"
|
||||
assert d["reason"] == "ok"
|
||||
|
||||
|
||||
@pytest.mark.ac("AC-2.1b")
|
||||
@pytest.mark.parametrize("reason", [
|
||||
"ok",
|
||||
"too_few_inliers",
|
||||
"mre_above_threshold",
|
||||
"degenerate_homography",
|
||||
"freshness_expired",
|
||||
])
|
||||
def test_anchor_decision_accepts_verify02_vocab(reason):
|
||||
"""REQUIREMENTS.md VERIFY-02 — exactly five reject-reason values are valid."""
|
||||
rec = AnchorDecision(decision="reject", reason=reason, n_inliers=0, mre_px=99.0)
|
||||
assert rec.reason == reason
|
||||
|
||||
|
||||
def test_anchor_decision_rejects_unknown_reason():
|
||||
with pytest.raises(ValidationError):
|
||||
AnchorDecision(decision="reject", reason="too_much_drift", n_inliers=0, mre_px=99.0)
|
||||
@@ -14,6 +14,8 @@ import time
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.unit]
|
||||
|
||||
from gps_denied.core.mavlink import (
|
||||
MAVLinkBridge,
|
||||
MockMAVConnection,
|
||||
@@ -73,6 +75,7 @@ def test_unix_to_gps_time_recent():
|
||||
# MAV-02: ESKF → GPS_INPUT field mapping
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@pytest.mark.ac("AC-4.3")
|
||||
def test_confidence_to_fix_type():
|
||||
"""MAV-02: confidence tier → fix_type mapping."""
|
||||
assert _confidence_to_fix_type(ConfidenceTier.HIGH) == 3
|
||||
@@ -81,6 +84,7 @@ def test_confidence_to_fix_type():
|
||||
assert _confidence_to_fix_type(ConfidenceTier.FAILED) == 0
|
||||
|
||||
|
||||
@pytest.mark.ac("AC-4.3")
|
||||
def test_eskf_to_gps_input_position():
|
||||
"""MAV-02: ENU position → degE7 lat/lon."""
|
||||
# 1° lat ≈ 111319.5 m; move 111319.5 m North → lat + 1°
|
||||
@@ -91,6 +95,7 @@ def test_eskf_to_gps_input_position():
|
||||
assert abs(msg.lat - expected_lat) <= 10 # within 1 µ-degree tolerance
|
||||
|
||||
|
||||
@pytest.mark.ac("AC-4.3")
|
||||
def test_eskf_to_gps_input_lon():
|
||||
"""MAV-02: East displacement → longitude shift."""
|
||||
cos_lat = math.cos(math.radians(ORIGIN.lat))
|
||||
@@ -210,6 +215,8 @@ def test_consecutive_failure_counter_increments_on_low(bridge):
|
||||
assert bridge._consecutive_failures == 2
|
||||
|
||||
|
||||
@pytest.mark.ac("AC-5.2")
|
||||
@pytest.mark.ac("AC-3.4")
|
||||
def test_reloc_request_triggered_after_3_failures(bridge):
|
||||
"""MAV-04: after 3 failures the re-localisation callback is called."""
|
||||
received: list[RelocalizationRequest] = []
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.unit]
|
||||
|
||||
from gps_denied.core.metric import MetricRefinement
|
||||
from gps_denied.core.models import ModelManager
|
||||
from gps_denied.schemas import GPSPoint
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"""Tests for Model Manager (F16)."""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from gps_denied.core.models import ModelManager
|
||||
|
||||
pytestmark = [pytest.mark.unit]
|
||||
|
||||
|
||||
def test_load_and_get_model():
|
||||
manager = ModelManager()
|
||||
|
||||
@@ -5,6 +5,8 @@ import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.unit]
|
||||
|
||||
from gps_denied.core.pipeline import ImageInputPipeline, QueueFullError
|
||||
from gps_denied.schemas.image import ImageBatch
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.integration]
|
||||
|
||||
from gps_denied.core.chunk_manager import RouteChunkManager
|
||||
from gps_denied.core.gpr import GlobalPlaceRecognition
|
||||
from gps_denied.core.graph import FactorGraphOptimizer
|
||||
|
||||
@@ -14,6 +14,8 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.integration]
|
||||
|
||||
from gps_denied.core.coordinates import CoordinateTransformer
|
||||
from gps_denied.core.processor import FlightProcessor, TrackingState
|
||||
from gps_denied.core.rotation import ImageRotationManager
|
||||
@@ -209,6 +211,7 @@ async def test_failure_counter_resets_on_recovery():
|
||||
# PIPE-07: ESKF state pushed to MAVLink
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@pytest.mark.ac("AC-4.4")
|
||||
@pytest.mark.asyncio
|
||||
async def test_mavlink_state_pushed_per_frame():
|
||||
"""PIPE-07: MAVLinkBridge.update_state called on every frame with ESKF."""
|
||||
@@ -281,6 +284,7 @@ async def test_convert_object_to_gps_fallback_without_coord():
|
||||
# ESKF initialization via create_flight
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@pytest.mark.ac("AC-1.4")
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_flight_initialises_eskf():
|
||||
"""create_flight should seed ESKF for the new flight."""
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.unit]
|
||||
|
||||
from gps_denied.core.chunk_manager import RouteChunkManager
|
||||
from gps_denied.core.gpr import GlobalPlaceRecognition
|
||||
from gps_denied.core.graph import FactorGraphOptimizer
|
||||
|
||||
@@ -5,6 +5,8 @@ from datetime import datetime, timezone
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.unit]
|
||||
|
||||
from gps_denied.core.rotation import IImageMatcher, ImageRotationManager
|
||||
from gps_denied.schemas import GPSPoint
|
||||
from gps_denied.schemas.rotation import RotationResult
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.unit]
|
||||
|
||||
from gps_denied.core.satellite import SatelliteDataManager
|
||||
from gps_denied.schemas import GPSPoint
|
||||
from gps_denied.utils import mercator
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.unit]
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from gps_denied.config import get_settings
|
||||
@@ -19,6 +22,7 @@ from gps_denied.schemas.flight import (
|
||||
|
||||
# ── GPSPoint ──────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.ac("AC-6.3")
|
||||
class TestGPSPoint:
|
||||
def test_valid(self):
|
||||
p = GPSPoint(lat=48.275, lon=37.385)
|
||||
@@ -99,6 +103,7 @@ class TestFlightCreateRequest:
|
||||
|
||||
# ── Waypoint ──────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.ac("AC-6.3")
|
||||
class TestWaypoint:
|
||||
def test_valid(self):
|
||||
wp = Waypoint(
|
||||
|
||||
@@ -42,10 +42,10 @@ SITL_PORT = int(os.environ.get("ARDUPILOT_SITL_PORT", "5762"))
|
||||
|
||||
_SITL_AVAILABLE = bool(SITL_HOST)
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
pytestmark = [pytest.mark.sitl, pytest.mark.skipif(
|
||||
not _SITL_AVAILABLE,
|
||||
reason="SITL integration tests require ARDUPILOT_SITL_HOST env var",
|
||||
)
|
||||
)]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
@@ -90,6 +90,7 @@ def _wait_for_tcp(host: str, port: int, timeout: float = 30.0) -> bool:
|
||||
# SITL-01: Connection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.ac("AC-4.3")
|
||||
def test_sitl_tcp_port_reachable():
|
||||
"""SITL-01: ArduPilot SITL TCP port is reachable before running tests."""
|
||||
reachable = _wait_for_tcp(SITL_HOST, SITL_PORT, timeout=30.0)
|
||||
@@ -99,6 +100,7 @@ def test_sitl_tcp_port_reachable():
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.ac("AC-4.3")
|
||||
def test_pymavlink_connection_to_sitl():
|
||||
"""SITL-01: pymavlink connects to SITL without error."""
|
||||
pytest.importorskip("pymavlink", reason="pymavlink not installed")
|
||||
@@ -115,6 +117,8 @@ def test_pymavlink_connection_to_sitl():
|
||||
# SITL-02: GPS_INPUT accepted by SITL EKF
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.ac("AC-4.3")
|
||||
@pytest.mark.ac("AC-NEW-2")
|
||||
def test_gps_input_accepted_by_sitl():
|
||||
"""SITL-02: Sending GPS_INPUT produces GPS_RAW_INT with fix_type >= 3."""
|
||||
pytest.importorskip("pymavlink", reason="pymavlink not installed")
|
||||
@@ -173,6 +177,7 @@ def test_gps_input_accepted_by_sitl():
|
||||
# SITL-03: MAVLinkBridge lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.ac("AC-4.3")
|
||||
@pytest.mark.asyncio
|
||||
async def test_mavlink_bridge_start_stop_with_sitl():
|
||||
"""SITL-03: MAVLinkBridge.start/stop with real SITL TCP connection."""
|
||||
@@ -228,6 +233,8 @@ async def test_imu_callback_fires_from_sitl():
|
||||
# SITL-05: GPS_INPUT rate ≥ 5 Hz
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.ac("AC-4.3")
|
||||
@pytest.mark.ac("AC-4.4")
|
||||
@pytest.mark.asyncio
|
||||
async def test_gps_input_rate_at_least_5hz():
|
||||
"""SITL-05: MAVLinkBridge delivers GPS_INPUT at ≥5 Hz over 1 second."""
|
||||
@@ -297,6 +304,8 @@ async def test_telemetry_reaches_sitl_at_1hz():
|
||||
# SITL-07: Reloc request after 3 consecutive failures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.ac("AC-5.2")
|
||||
@pytest.mark.ac("AC-3.4")
|
||||
@pytest.mark.asyncio
|
||||
async def test_reloc_request_after_3_failures_with_sitl():
|
||||
"""SITL-07: After 3 FAILED-confidence updates, reloc callback fires."""
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.unit]
|
||||
|
||||
from gps_denied.core.models import ModelManager
|
||||
from gps_denied.core.vo import (
|
||||
CuVSLAMVisualOdometry,
|
||||
|
||||
Reference in New Issue
Block a user