Files
satellite-provider/_docs/02_document/data_model.md
T
Oleksandr Bezdieniezhnykh 1802d32107 [AZ-488] UAV tile batch upload + 5-rule quality gate
Replaces the 501 stub at POST /api/satellite/upload with a multipart
batch endpoint that ingests UAV-captured tiles, runs each item through
a 5-rule quality gate, and persists accepted tiles via the AZ-484
multi-source storage path with source='uav'.

Quality gate (in fixed order, first failure wins): JPEG format
(content-type + magic), size band 5 KiB-5 MiB, exact 256x256
dimensions, captured-at age (no future >30 s skew, no older than
7 days), luminance variance on 32x32 downsample. Closed reject-reason
enumeration in v1.0.0 contract.

Authorization: custom PermissionsRequirement / PermissionsAuthorization
Handler that reads the JWT `permissions` claim (tolerates both
repeated-string and JSON-array shapes). Endpoint protected by
RequiresGpsPermission policy; 401 without token, 403 without GPS perm.

Persistence: file-first to ./tiles/uav/{z}/{x}/{y}.jpg, then
ITileRepository.InsertAsync UPSERT (per-source UPSERT contract from
AZ-484). Per-item failures reported in response without aborting the
batch. Kestrel MaxRequestBodySize and FormOptions limits set to
MaxBatchSize x MaxBytes (default 100 x 5 MiB = 500 MiB).

New frozen contract: _docs/02_document/contracts/api/uav-tile-upload.md
v1.0.0. PT-08 NFR added to performance-tests.md as Deferred (harness
work tracked in PT-07 leftover, per AZ-488 § Risk 4).

Tests: 11 quality-gate unit tests, 5 handler unit tests, 3 file-path
unit tests, 12 permission-handler unit tests, 7 integration tests
(AC-1..AC-6, AC-8). All 253 unit tests + smoke integration suite
green.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 23:50:49 +03:00

228 lines
10 KiB
Markdown

# Satellite Provider — Data Model
## Entity-Relationship Diagram
```mermaid
erDiagram
TILES {
uuid id PK
int tile_zoom
float latitude
float longitude
float tile_size_meters
int tile_size_pixels
varchar image_type
varchar maps_version
int version
varchar source
timestamp captured_at
varchar file_path
int tile_x
int tile_y
timestamp created_at
timestamp updated_at
}
REGIONS {
uuid id PK
float latitude
float longitude
float size_meters
int zoom_level
varchar status
bool stitch_tiles
varchar csv_file_path
varchar summary_file_path
int tiles_downloaded
int tiles_reused
timestamp created_at
timestamp updated_at
}
ROUTES {
uuid id PK
varchar name
text description
float region_size_meters
int zoom_level
float total_distance_meters
int total_points
bool request_maps
bool maps_ready
bool create_tiles_zip
varchar tiles_zip_path
varchar csv_file_path
varchar summary_file_path
varchar stitched_image_path
timestamp created_at
timestamp updated_at
}
ROUTE_POINTS {
uuid id PK
uuid route_id FK
int sequence_number
float latitude
float longitude
varchar point_type
int segment_index
float distance_from_previous
timestamp created_at
}
ROUTE_REGIONS {
uuid route_id FK
uuid region_id FK
bool is_geofence
int geofence_polygon_index
timestamp created_at
}
ROUTES ||--o{ ROUTE_POINTS : "has many"
ROUTES ||--o{ ROUTE_REGIONS : "has many"
REGIONS ||--o{ ROUTE_REGIONS : "linked via"
```
## Tables
### tiles
Stores metadata for downloaded satellite imagery tiles. Each tile is a single image at a specific geographic coordinate and zoom level.
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| id | UUID | PK | Unique tile identifier |
| tile_zoom | INT | NOT NULL | Google Maps zoom level (1-20) |
| latitude | DOUBLE PRECISION | NOT NULL | Center latitude |
| longitude | DOUBLE PRECISION | NOT NULL | Center longitude |
| tile_size_meters | DOUBLE PRECISION | NOT NULL | Ground coverage in meters |
| tile_size_pixels | INT | NOT NULL | Image dimension in pixels |
| image_type | VARCHAR(10) | NOT NULL | Image format (e.g., "jpg") |
| maps_version | VARCHAR(50) | | Legacy free-form provider tag; post-AZ-373 new rows write NULL. Vestigial post-AZ-484 (column retained for forensics on pre-existing rows; no longer part of any index) |
| version | INT | NOT NULL, DEFAULT 2025 | Year-based versioning for cache invalidation. Vestigial post-AZ-484 — removed from the unique key by migration 012 (preparation for AZ-484); column retained nullable for backward compatibility |
| source | VARCHAR(32) | NOT NULL, DEFAULT 'google_maps' | AZ-484: producer of the imagery (`'google_maps'`, `'uav'`). Closed value set — see `tile-storage` v1.0.0 contract Inv-5 and `Common.Enums.TileSourceConverter`. Backfilled to `'google_maps'` for all pre-AZ-484 rows by migration 013 |
| captured_at | TIMESTAMP | NOT NULL | AZ-484: imagery acquisition timestamp (UTC). Drives most-recent-across-sources selection. Backfilled to `created_at` for pre-AZ-484 rows by migration 013 |
| file_path | VARCHAR(500) | NOT NULL | Relative path to stored image. **AZ-488 per-source layout**: `source='google_maps'` rows keep the legacy bucketed/timestamped path emitted by `StorageConfig.GetTileFilePath` (`{TilesDirectory}/{zoom}/{x_bucket}/{y_bucket}/tile_{zoom}_{x}_{y}_{ts}.jpg`). `source='uav'` rows live under `{TilesDirectory}/uav/{zoom}/{x}/{y}.jpg` — see `_docs/02_document/contracts/api/uav-tile-upload.md` v1.0.0. The authoritative source marker is the `source` column; the per-source path is implementation detail that keeps both producers' bytes individually addressable. |
| tile_x | INT | NOT NULL | Tile X coordinate (Slippy Map) |
| tile_y | INT | NOT NULL | Tile Y coordinate (Slippy Map) |
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW | |
| updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW | |
**Indexes** (post-AZ-484):
- `idx_tiles_unique_location_source` UNIQUE (latitude, longitude, tile_zoom, tile_size_meters, source) — created by migration 013; replaces the pre-AZ-484 4-col `idx_tiles_unique_location` (which itself superseded the legacy 5-col `(…, version)` index dropped by migration 012)
- `idx_tiles_coordinates` (tile_zoom, tile_x, tile_y, version)
- `idx_tiles_zoom` (tile_zoom)
**Selection rule**: `GetByTileCoordinatesAsync` and `GetTilesByRegionAsync` return the most-recent row across sources for any `(latitude, longitude, tile_zoom, tile_size_meters)` cell. Tie-break: `captured_at DESC, updated_at DESC, id DESC`. Region read uses `DISTINCT ON` to enforce one-row-per-cell at the SQL layer.
**UPSERT contract**: `INSERT … ON CONFLICT (latitude, longitude, tile_zoom, tile_size_meters, source) DO UPDATE` — same-source re-insert refreshes `file_path, tile_x, tile_y, captured_at, updated_at`. Two producers for the same cell coexist as separate rows.
### regions
Tracks region download requests and their processing status.
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| id | UUID | PK | Region request identifier |
| latitude | DOUBLE PRECISION | NOT NULL | Center latitude |
| longitude | DOUBLE PRECISION | NOT NULL | Center longitude |
| size_meters | DOUBLE PRECISION | NOT NULL | Square region side length |
| zoom_level | INT | NOT NULL | Zoom level for tiles |
| status | VARCHAR(20) | NOT NULL | pending / processing / completed / failed |
| stitch_tiles | BOOLEAN | NOT NULL, DEFAULT false | Whether to produce stitched image |
| csv_file_path | VARCHAR(500) | | Path to tile manifest CSV |
| summary_file_path | VARCHAR(500) | | Path to summary text |
| tiles_downloaded | INT | DEFAULT 0 | Count of newly downloaded tiles |
| tiles_reused | INT | DEFAULT 0 | Count of cache-hit tiles |
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW | |
| updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW | |
**Indexes**:
- `idx_regions_status` (status)
### routes
Defines route paths with configuration for map tile generation.
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| id | UUID | PK | Route identifier |
| name | VARCHAR(200) | NOT NULL | Human-readable name |
| description | TEXT | | Optional description |
| region_size_meters | DOUBLE PRECISION | NOT NULL | Size of region per point |
| zoom_level | INT | NOT NULL | Zoom level for regions |
| total_distance_meters | DOUBLE PRECISION | NOT NULL | Total route length |
| total_points | INT | NOT NULL | Total point count (original + interpolated) |
| request_maps | BOOLEAN | NOT NULL, DEFAULT false | Whether to generate map tiles |
| maps_ready | BOOLEAN | NOT NULL, DEFAULT false | Whether map generation is complete |
| create_tiles_zip | BOOLEAN | NOT NULL, DEFAULT false | Whether to produce ZIP archive |
| tiles_zip_path | VARCHAR(500) | | Path to output ZIP |
| csv_file_path | VARCHAR(500) | | Route-level CSV |
| summary_file_path | VARCHAR(500) | | Route-level summary |
| stitched_image_path | VARCHAR(500) | | Route-level stitched image |
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW | |
| updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW | |
### route_points
Stores all points along a route (both original waypoints and interpolated intermediate points).
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| id | UUID | PK | Point identifier |
| route_id | UUID | FK → routes.id, CASCADE | Parent route |
| sequence_number | INT | NOT NULL, UNIQUE(route_id, seq) | Order along route |
| latitude | DOUBLE PRECISION | NOT NULL | Point latitude |
| longitude | DOUBLE PRECISION | NOT NULL | Point longitude |
| point_type | VARCHAR(20) | NOT NULL | "original" or "intermediate" |
| segment_index | INT | NOT NULL | Which segment (between original points) |
| distance_from_previous | DOUBLE PRECISION | | Meters from previous point |
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW | |
**Indexes**:
- `idx_route_points_route` (route_id, sequence_number)
- `idx_route_points_coords` (latitude, longitude)
### route_regions
Junction table linking routes to their generated region requests, with geofence metadata.
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| route_id | UUID | FK → routes.id, CASCADE, PK | |
| region_id | UUID | FK → regions.id, CASCADE, PK | |
| is_geofence | BOOLEAN | NOT NULL, DEFAULT false | Whether point is inside a geofence |
| geofence_polygon_index | INTEGER | | Which polygon (0-based) the point is in |
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW | |
**Indexes**:
- `idx_route_regions_route` (route_id)
- `idx_route_regions_region` (region_id)
## Migration Strategy
- **Tool**: DbUp (embedded SQL scripts)
- **Execution**: Automatic on application startup (`DatabaseMigrator.Migrate()`)
- **Naming**: `NNN_DescriptiveName.sql` (sequential numbering)
- **Storage**: Embedded resources in `SatelliteProvider.DataAccess` assembly
- **Tracking**: DbUp's internal `schemaversions` table records which scripts have run
- **Rollback**: Not supported — forward-only migrations
## Migration History
| # | Migration | Purpose |
|---|-----------|---------|
| 001 | CreateTilesTable | Base tiles table |
| 002 | CreateRegionsTable | Region request tracking |
| 003 | CreateIndexes | Performance indexes |
| 004 | AddVersionColumn | Year-based tile versioning + dedup |
| 005 | CreateRoutesTables | Routes, route_points, route_regions |
| 006 | AddStitchTilesToRegions | Stitch flag on regions |
| 007 | AddRouteMapFields | request_maps, maps_ready, file paths on routes |
| 008 | AddGeofenceFlagToRouteRegions | is_geofence flag |
| 009 | AddGeofencePolygonIndex | Polygon index tracking |
| 010 | AddTilesZipToRoutes | ZIP generation fields |
| 011 | AddTileCoordinates | Slippy map X/Y + rename zoom_level → tile_zoom |
| 012 | DropTileVersionConstraint | Drops legacy 5-col `(…, version)` unique index; replaces with 4-col `idx_tiles_unique_location` (preparation for AZ-484) |
| 013 | AddTileSourceAndCapturedAt | AZ-484: adds `source` (default `'google_maps'`) + `captured_at` columns; backfills both for pre-existing rows; replaces 4-col unique with 5-col `idx_tiles_unique_location_source`. Transactional; idempotent against partial replays |