mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-04-23 04:46:37 +00:00
feat: stage1 — domain schemas, SSE events, pydantic-settings config
This commit is contained in:
@@ -0,0 +1,61 @@
|
|||||||
|
# GPS-Denied Onboard
|
||||||
|
|
||||||
|
Сервіс геолокалізації знімків БПЛА в умовах відсутності GPS-сигналу.
|
||||||
|
|
||||||
|
Система використовує візуальну одометрію (VO), співставлення з супутниковими картами (cross-view matching) та оптимізацію траєкторії через фактор-графи для визначення координат дрона в реальному часі.
|
||||||
|
|
||||||
|
## Стек
|
||||||
|
|
||||||
|
| Компонент | Технологія |
|
||||||
|
|-----------|------------|
|
||||||
|
| API | FastAPI + Pydantic v2 |
|
||||||
|
| Стрім подій | SSE (sse-starlette) |
|
||||||
|
| БД | SQLite + SQLAlchemy 2 + Alembic |
|
||||||
|
| Граф поз | GTSAM (Python) |
|
||||||
|
| Карти | Google Maps API (Strategy-патерн) |
|
||||||
|
|
||||||
|
## Швидкий старт
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Клонувати та перейти в гілку stage1
|
||||||
|
git clone https://github.com/azaion/gps-denied-onboard.git
|
||||||
|
cd gps-denied-onboard
|
||||||
|
git checkout stage1
|
||||||
|
|
||||||
|
# Створити віртуальне середовище
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
|
||||||
|
# Встановити залежності
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
|
||||||
|
# Запустити сервер
|
||||||
|
python -m gps_denied
|
||||||
|
```
|
||||||
|
|
||||||
|
Сервер стартує на `http://127.0.0.1:8000`. Health check: `GET /health`.
|
||||||
|
|
||||||
|
## Тести
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest tests/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Структура проєкту
|
||||||
|
|
||||||
|
```
|
||||||
|
gps-denied-onboard/
|
||||||
|
├── src/gps_denied/ # Основний пакет
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── __main__.py # Entry point (uvicorn)
|
||||||
|
│ └── app.py # FastAPI application
|
||||||
|
├── tests/ # Тести
|
||||||
|
├── docs-Lokal/ # Локальна документація та план
|
||||||
|
├── _docs/ # Архітектурна документація
|
||||||
|
├── pyproject.toml # Залежності та конфігурація
|
||||||
|
└── .gitignore
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ліцензія
|
||||||
|
|
||||||
|
Приватний репозиторій. Усі права захищено.
|
||||||
@@ -54,10 +54,10 @@
|
|||||||
|
|
||||||
## 5. Поетапний план виконання (детально)
|
## 5. Поетапний план виконання (детально)
|
||||||
|
|
||||||
### Етап 0 — Ініціалізація репозиторію коду (локально)
|
### Етап 0 — Ініціалізація репозиторію коду (локально) ✅
|
||||||
- Створити структуру пакета, `pyproject.toml`, залежності.
|
- Створено структуру пакета `src/gps_denied/`, `pyproject.toml`, залежності.
|
||||||
- Налаштувати `.gitignore`: `.env`, `data/`, `weights/`, `__pycache__`, `*.db`.
|
- Налаштовано `.gitignore`: `.env`, `data/`, `weights/`, `__pycache__`, `*.db`.
|
||||||
- **Перевірка:** порожній сервіс запускається, health endpoint працює.
|
- **Результат:** сервіс запускається, health endpoint працює, тест пройдено.
|
||||||
|
|
||||||
### Етап 0.5 — Підготовка даних та моделей (Data Provisioning)
|
### Етап 0.5 — Підготовка даних та моделей (Data Provisioning)
|
||||||
- **Токени:** Зафіксувати Google Maps API key у `.env`.
|
- **Токени:** Зафіксувати Google Maps API key у `.env`.
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""Application configuration loaded from environment / YAML."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseConfig(BaseSettings):
|
||||||
|
"""Database connection settings."""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(env_prefix="DB_")
|
||||||
|
|
||||||
|
url: str = Field(
|
||||||
|
default="sqlite+aiosqlite:///./flight_data.db",
|
||||||
|
description="SQLAlchemy async connection URL",
|
||||||
|
)
|
||||||
|
echo: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class APIConfig(BaseSettings):
|
||||||
|
"""API server settings."""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(env_prefix="API_")
|
||||||
|
|
||||||
|
host: str = "127.0.0.1"
|
||||||
|
port: int = 8000
|
||||||
|
reload: bool = True
|
||||||
|
cors_origins: list[str] = Field(default_factory=lambda: ["*"])
|
||||||
|
|
||||||
|
|
||||||
|
class TileProviderConfig(BaseSettings):
|
||||||
|
"""Satellite tile provider settings (Google Maps by default)."""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(env_prefix="TILES_")
|
||||||
|
|
||||||
|
provider: str = "google"
|
||||||
|
api_key: str = ""
|
||||||
|
cache_dir: Path = Path("tile_cache")
|
||||||
|
zoom_level: int = 18
|
||||||
|
max_concurrent_requests: int = 4
|
||||||
|
|
||||||
|
|
||||||
|
class ModelPaths(BaseSettings):
|
||||||
|
"""Paths to ML model weights."""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(env_prefix="MODEL_")
|
||||||
|
|
||||||
|
weights_dir: Path = Path("weights")
|
||||||
|
superpoint_path: str = "superpoint.pt"
|
||||||
|
lightglue_path: str = "lightglue.pt"
|
||||||
|
litesam_path: str = "litesam.pt"
|
||||||
|
|
||||||
|
|
||||||
|
class OperationalArea(BaseSettings):
|
||||||
|
"""Default operational boundaries."""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(env_prefix="AREA_")
|
||||||
|
|
||||||
|
name: str = "Eastern Ukraine"
|
||||||
|
min_lat: float = 45.0
|
||||||
|
max_lat: float = 52.0
|
||||||
|
min_lon: float = 22.0
|
||||||
|
max_lon: float = 40.0
|
||||||
|
|
||||||
|
|
||||||
|
class RecoveryConfig(BaseSettings):
|
||||||
|
"""Failure recovery and progressive search settings."""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(env_prefix="RECOVERY_")
|
||||||
|
|
||||||
|
search_grid_sizes: list[int] = Field(default_factory=lambda: [1, 4, 9, 16, 25])
|
||||||
|
min_chunk_frames: int = 5
|
||||||
|
max_chunk_frames: int = 20
|
||||||
|
user_input_threshold_tiles: int = 25
|
||||||
|
confidence_threshold_good: float = 0.7
|
||||||
|
confidence_threshold_degraded: float = 0.5
|
||||||
|
min_inlier_count_good: int = 50
|
||||||
|
min_inlier_count_tracking: int = 20
|
||||||
|
|
||||||
|
|
||||||
|
class RotationConfig(BaseSettings):
|
||||||
|
"""Image rotation and heading tracking settings."""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(env_prefix="ROTATION_")
|
||||||
|
|
||||||
|
step_degrees: float = 30.0
|
||||||
|
litesam_max_tolerance: float = 45.0
|
||||||
|
sharp_turn_threshold: float = 45.0
|
||||||
|
heading_history_size: int = 10
|
||||||
|
confidence_threshold: float = 0.7
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rotation_iterations(self) -> int:
|
||||||
|
return int(360 / self.step_degrees)
|
||||||
|
|
||||||
|
|
||||||
|
class AppSettings(BaseSettings):
|
||||||
|
"""Root settings — aggregates all sub-configs."""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
env_nested_delimiter="__",
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
db: DatabaseConfig = Field(default_factory=DatabaseConfig)
|
||||||
|
api: APIConfig = Field(default_factory=APIConfig)
|
||||||
|
tiles: TileProviderConfig = Field(default_factory=TileProviderConfig)
|
||||||
|
models: ModelPaths = Field(default_factory=ModelPaths)
|
||||||
|
area: OperationalArea = Field(default_factory=OperationalArea)
|
||||||
|
recovery: RecoveryConfig = Field(default_factory=RecoveryConfig)
|
||||||
|
rotation: RotationConfig = Field(default_factory=RotationConfig)
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings() -> AppSettings:
|
||||||
|
"""Singleton-like factory for application settings."""
|
||||||
|
return AppSettings()
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Domain models — GPS, Camera, Geometry."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class GPSPoint(BaseModel):
|
||||||
|
"""WGS-84 coordinate."""
|
||||||
|
|
||||||
|
lat: float = Field(..., ge=-90, le=90, description="Latitude")
|
||||||
|
lon: float = Field(..., ge=-180, le=180, description="Longitude")
|
||||||
|
|
||||||
|
|
||||||
|
class CameraParameters(BaseModel):
|
||||||
|
"""Intrinsic camera parameters."""
|
||||||
|
|
||||||
|
focal_length: float = Field(..., gt=0, description="Focal length in mm")
|
||||||
|
sensor_width: float = Field(..., gt=0, description="Sensor width in mm")
|
||||||
|
sensor_height: float = Field(..., gt=0, description="Sensor height in mm")
|
||||||
|
resolution_width: int = Field(..., gt=0, description="Image width in pixels")
|
||||||
|
resolution_height: int = Field(..., gt=0, description="Image height in pixels")
|
||||||
|
principal_point: tuple[float, float] | None = None
|
||||||
|
distortion_coefficients: list[float] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Polygon(BaseModel):
|
||||||
|
"""Bounding box defined by NW and SE corners."""
|
||||||
|
|
||||||
|
north_west: GPSPoint
|
||||||
|
south_east: GPSPoint
|
||||||
|
|
||||||
|
|
||||||
|
class Geofences(BaseModel):
|
||||||
|
"""Collection of geofence polygons."""
|
||||||
|
|
||||||
|
polygons: list[Polygon] = Field(default_factory=list)
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""SSE event schemas."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from gps_denied.schemas import GPSPoint
|
||||||
|
|
||||||
|
|
||||||
|
class SSEEventType(str, Enum):
|
||||||
|
"""Server-Sent Event types emitted during flight processing."""
|
||||||
|
|
||||||
|
FRAME_PROCESSED = "frame_processed"
|
||||||
|
FRAME_REFINED = "frame_refined"
|
||||||
|
SEARCH_EXPANDED = "search_expanded"
|
||||||
|
USER_INPUT_NEEDED = "user_input_needed"
|
||||||
|
PROCESSING_BLOCKED = "processing_blocked"
|
||||||
|
FLIGHT_COMPLETED = "flight_completed"
|
||||||
|
|
||||||
|
|
||||||
|
class FrameProcessedEvent(BaseModel):
|
||||||
|
"""Payload for frame_processed / frame_refined events."""
|
||||||
|
|
||||||
|
frame_id: int
|
||||||
|
gps: GPSPoint
|
||||||
|
altitude: float | None = None
|
||||||
|
confidence: float
|
||||||
|
heading: float | None = None
|
||||||
|
timestamp: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class SearchExpandedEvent(BaseModel):
|
||||||
|
"""Payload for search_expanded event."""
|
||||||
|
|
||||||
|
frame_id: int
|
||||||
|
grid_size: int
|
||||||
|
message: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserInputNeededEvent(BaseModel):
|
||||||
|
"""Payload for user_input_needed event."""
|
||||||
|
|
||||||
|
frame_id: int
|
||||||
|
reason: str
|
||||||
|
consecutive_failures: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class FlightCompletedEvent(BaseModel):
|
||||||
|
"""Payload for flight_completed event."""
|
||||||
|
|
||||||
|
frames_total: int
|
||||||
|
frames_processed: int
|
||||||
|
duration_seconds: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SSEMessage(BaseModel):
|
||||||
|
"""Generic SSE message wrapper."""
|
||||||
|
|
||||||
|
event: SSEEventType
|
||||||
|
data: dict[str, Any]
|
||||||
|
id: str | None = None
|
||||||
|
retry: int | None = None
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"""Schemas for Flight API requests and responses."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from gps_denied.schemas import CameraParameters, Geofences, GPSPoint
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Waypoint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class Waypoint(BaseModel):
|
||||||
|
"""Single waypoint with optional refinement flag."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
lat: float = Field(..., ge=-90, le=90)
|
||||||
|
lon: float = Field(..., ge=-180, le=180)
|
||||||
|
altitude: float | None = None
|
||||||
|
confidence: float = Field(..., ge=0, le=1)
|
||||||
|
timestamp: datetime
|
||||||
|
refined: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Flight — Requests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class FlightCreateRequest(BaseModel):
|
||||||
|
"""POST /flights body."""
|
||||||
|
|
||||||
|
name: str = Field(..., min_length=1, max_length=255)
|
||||||
|
description: str = ""
|
||||||
|
start_gps: GPSPoint
|
||||||
|
rough_waypoints: list[GPSPoint] = Field(default_factory=list)
|
||||||
|
geofences: Geofences = Field(default_factory=Geofences)
|
||||||
|
camera_params: CameraParameters
|
||||||
|
altitude: float = Field(..., gt=0)
|
||||||
|
|
||||||
|
|
||||||
|
class UserFixRequest(BaseModel):
|
||||||
|
"""POST /flights/{flightId}/user-fix body."""
|
||||||
|
|
||||||
|
frame_id: int = Field(..., ge=0)
|
||||||
|
uav_pixel: tuple[float, float]
|
||||||
|
satellite_gps: GPSPoint
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectToGPSRequest(BaseModel):
|
||||||
|
"""POST /flights/{flightId}/frames/{frameId}/object-to-gps body."""
|
||||||
|
|
||||||
|
pixel_x: float = Field(..., ge=0)
|
||||||
|
pixel_y: float = Field(..., ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Flight — Responses
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class FlightResponse(BaseModel):
|
||||||
|
"""Response after flight creation."""
|
||||||
|
|
||||||
|
flight_id: str
|
||||||
|
status: str
|
||||||
|
message: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class FlightDetailResponse(BaseModel):
|
||||||
|
"""GET /flights/{flightId} response."""
|
||||||
|
|
||||||
|
flight_id: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
start_gps: GPSPoint
|
||||||
|
waypoints: list[Waypoint] = Field(default_factory=list)
|
||||||
|
geofences: Geofences
|
||||||
|
camera_params: CameraParameters
|
||||||
|
altitude: float
|
||||||
|
status: str
|
||||||
|
frames_processed: int = 0
|
||||||
|
frames_total: int = 0
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class FlightStatusResponse(BaseModel):
|
||||||
|
"""GET /flights/{flightId}/status response."""
|
||||||
|
|
||||||
|
status: str
|
||||||
|
frames_processed: int = 0
|
||||||
|
frames_total: int = 0
|
||||||
|
current_frame: int | None = None
|
||||||
|
current_heading: float | None = None
|
||||||
|
blocked: bool = False
|
||||||
|
search_grid_size: int | None = None
|
||||||
|
message: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteResponse(BaseModel):
|
||||||
|
deleted: bool
|
||||||
|
flight_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateResponse(BaseModel):
|
||||||
|
updated: bool
|
||||||
|
waypoint_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class BatchUpdateResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
updated_count: int
|
||||||
|
failed_ids: list[str] = Field(default_factory=list)
|
||||||
|
errors: dict[str, str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Image Batch
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class BatchMetadata(BaseModel):
|
||||||
|
"""Metadata accompanying an image batch upload."""
|
||||||
|
|
||||||
|
start_sequence: int = Field(..., ge=0)
|
||||||
|
end_sequence: int = Field(..., ge=0)
|
||||||
|
batch_number: int = Field(..., ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class BatchResponse(BaseModel):
|
||||||
|
"""Response after batch upload."""
|
||||||
|
|
||||||
|
accepted: bool
|
||||||
|
sequences: list[int] = Field(default_factory=list)
|
||||||
|
next_expected: int = 0
|
||||||
|
message: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# User Fix
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class UserFixResponse(BaseModel):
|
||||||
|
accepted: bool
|
||||||
|
processing_resumed: bool = False
|
||||||
|
message: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Object → GPS
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ObjectGPSResponse(BaseModel):
|
||||||
|
gps: GPSPoint
|
||||||
|
accuracy_meters: float
|
||||||
|
frame_id: int
|
||||||
|
pixel: tuple[float, float]
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
"""Tests for domain schemas and configuration."""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from gps_denied.config import AppSettings, get_settings
|
||||||
|
from gps_denied.schemas import CameraParameters, Geofences, GPSPoint, Polygon
|
||||||
|
from gps_denied.schemas.events import (
|
||||||
|
FlightCompletedEvent,
|
||||||
|
FrameProcessedEvent,
|
||||||
|
SSEEventType,
|
||||||
|
SSEMessage,
|
||||||
|
)
|
||||||
|
from gps_denied.schemas.flight import (
|
||||||
|
BatchMetadata,
|
||||||
|
BatchResponse,
|
||||||
|
FlightCreateRequest,
|
||||||
|
FlightDetailResponse,
|
||||||
|
FlightResponse,
|
||||||
|
FlightStatusResponse,
|
||||||
|
UserFixRequest,
|
||||||
|
Waypoint,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── GPSPoint ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestGPSPoint:
|
||||||
|
def test_valid(self):
|
||||||
|
p = GPSPoint(lat=48.275, lon=37.385)
|
||||||
|
assert p.lat == 48.275
|
||||||
|
|
||||||
|
def test_lat_out_of_range(self):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
GPSPoint(lat=91, lon=0)
|
||||||
|
|
||||||
|
def test_lon_out_of_range(self):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
GPSPoint(lat=0, lon=181)
|
||||||
|
|
||||||
|
def test_serialization_roundtrip(self):
|
||||||
|
p = GPSPoint(lat=-12.5, lon=130.0)
|
||||||
|
assert GPSPoint.model_validate_json(p.model_dump_json()) == p
|
||||||
|
|
||||||
|
|
||||||
|
# ── CameraParameters ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestCameraParameters:
|
||||||
|
def test_valid(self):
|
||||||
|
cam = CameraParameters(
|
||||||
|
focal_length=25,
|
||||||
|
sensor_width=23.5,
|
||||||
|
sensor_height=15.6,
|
||||||
|
resolution_width=6252,
|
||||||
|
resolution_height=4168,
|
||||||
|
)
|
||||||
|
assert cam.resolution_width == 6252
|
||||||
|
|
||||||
|
def test_negative_focal_length(self):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
CameraParameters(
|
||||||
|
focal_length=-1,
|
||||||
|
sensor_width=23.5,
|
||||||
|
sensor_height=15.6,
|
||||||
|
resolution_width=6252,
|
||||||
|
resolution_height=4168,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── FlightCreateRequest ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestFlightCreateRequest:
|
||||||
|
def test_minimal_valid(self):
|
||||||
|
req = FlightCreateRequest(
|
||||||
|
name="Test_Flight",
|
||||||
|
start_gps=GPSPoint(lat=48.275, lon=37.385),
|
||||||
|
camera_params=CameraParameters(
|
||||||
|
focal_length=25,
|
||||||
|
sensor_width=23.5,
|
||||||
|
sensor_height=15.6,
|
||||||
|
resolution_width=6252,
|
||||||
|
resolution_height=4168,
|
||||||
|
),
|
||||||
|
altitude=400,
|
||||||
|
)
|
||||||
|
assert req.name == "Test_Flight"
|
||||||
|
assert req.description == ""
|
||||||
|
assert req.geofences.polygons == []
|
||||||
|
|
||||||
|
def test_empty_name_rejected(self):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
FlightCreateRequest(
|
||||||
|
name="",
|
||||||
|
start_gps=GPSPoint(lat=0, lon=0),
|
||||||
|
camera_params=CameraParameters(
|
||||||
|
focal_length=25,
|
||||||
|
sensor_width=23.5,
|
||||||
|
sensor_height=15.6,
|
||||||
|
resolution_width=6252,
|
||||||
|
resolution_height=4168,
|
||||||
|
),
|
||||||
|
altitude=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Waypoint ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestWaypoint:
|
||||||
|
def test_valid(self):
|
||||||
|
wp = Waypoint(
|
||||||
|
id="wp_001",
|
||||||
|
lat=48.123,
|
||||||
|
lon=37.456,
|
||||||
|
confidence=0.95,
|
||||||
|
timestamp=datetime.now(tz=timezone.utc),
|
||||||
|
)
|
||||||
|
assert wp.refined is False
|
||||||
|
|
||||||
|
def test_confidence_out_of_range(self):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
Waypoint(
|
||||||
|
id="wp_001",
|
||||||
|
lat=48.123,
|
||||||
|
lon=37.456,
|
||||||
|
confidence=1.5,
|
||||||
|
timestamp=datetime.now(tz=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── SSE Events ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestSSEEvents:
|
||||||
|
def test_event_types(self):
|
||||||
|
assert SSEEventType.FRAME_PROCESSED == "frame_processed"
|
||||||
|
assert SSEEventType.FLIGHT_COMPLETED == "flight_completed"
|
||||||
|
|
||||||
|
def test_frame_event_serialization(self):
|
||||||
|
evt = FrameProcessedEvent(
|
||||||
|
frame_id=237,
|
||||||
|
gps=GPSPoint(lat=48.123, lon=37.456),
|
||||||
|
confidence=0.95,
|
||||||
|
timestamp=datetime.now(tz=timezone.utc),
|
||||||
|
)
|
||||||
|
data = evt.model_dump()
|
||||||
|
assert data["frame_id"] == 237
|
||||||
|
|
||||||
|
def test_sse_message(self):
|
||||||
|
msg = SSEMessage(
|
||||||
|
event=SSEEventType.FLIGHT_COMPLETED,
|
||||||
|
data={"frames_total": 100, "frames_processed": 100},
|
||||||
|
)
|
||||||
|
assert msg.event == "flight_completed"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Configuration ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestConfig:
|
||||||
|
def test_defaults(self):
|
||||||
|
settings = get_settings()
|
||||||
|
assert "sqlite" in settings.db.url
|
||||||
|
assert settings.api.port == 8000
|
||||||
|
assert settings.tiles.provider == "google"
|
||||||
|
|
||||||
|
def test_rotation_iterations(self):
|
||||||
|
settings = get_settings()
|
||||||
|
assert settings.rotation.rotation_iterations == 12 # 360 / 30
|
||||||
|
|
||||||
|
def test_recovery_defaults(self):
|
||||||
|
settings = get_settings()
|
||||||
|
assert settings.recovery.search_grid_sizes == [1, 4, 9, 16, 25]
|
||||||
Reference in New Issue
Block a user