mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 07:01:14 +00:00
Compare commits
10 Commits
2b53168142
...
c3a1ebc754
| Author | SHA1 | Date | |
|---|---|---|---|
| c3a1ebc754 | |||
| c7cd9b414d | |||
| 55a6e8ce12 | |||
| 5e52779056 | |||
| 63c0217e3d | |||
| b15454b9a9 | |||
| 811b04e605 | |||
| 544b37fdc9 | |||
| 3c2b63ce22 | |||
| 1198890b74 |
@@ -5,3 +5,40 @@
|
|||||||
- When a task requires changes in another repository (e.g., admin API, flights, UI), **document** the required changes in the task's implementation notes or a dedicated cross-repo doc — do not implement them.
|
- When a task requires changes in another repository (e.g., admin API, flights, UI), **document** the required changes in the task's implementation notes or a dedicated cross-repo doc — do not implement them.
|
||||||
- The mock API at `e2e/mocks/mock_api/` may be updated to reflect the expected contract of external services, but this is a test mock — not the real implementation.
|
- The mock API at `e2e/mocks/mock_api/` may be updated to reflect the expected contract of external services, but this is a test mock — not the real implementation.
|
||||||
- If a task is entirely scoped to another repository, mark it as out-of-scope for this workspace and note the target repository.
|
- If a task is entirely scoped to another repository, mark it as out-of-scope for this workspace and note the target repository.
|
||||||
|
|
||||||
|
## Exception — Adding Task Specs to Sibling Repos
|
||||||
|
|
||||||
|
The ONLY permitted form of writing into a sibling repository is **creating task-spec markdown files** (and updating the matching `_dependencies_table.md`) in that repo's `_docs/02_tasks/todo/` directory, and ONLY when the user explicitly asks for it in the current turn.
|
||||||
|
|
||||||
|
- "Explicit" means the user names the action (e.g. "add the md files to satellite-provider", "create the task spec there", "mirror it into their repo"). Inference from context is NOT enough — ask first.
|
||||||
|
- Mirror the sibling repo's existing template (read ONE of their `done/` task files to learn the format — this is process documentation, not source code).
|
||||||
|
- NEVER commit or push in the sibling repo unless the user separately and explicitly authorizes it. Default is "write to disk, leave for their review".
|
||||||
|
- Update `_dependencies_table.md` to keep it consistent with the new task files.
|
||||||
|
- The exception covers task specs ONLY. It does NOT extend to source code, CI/compose files, README, design docs, scripts, env templates, or any other file type in the sibling repo.
|
||||||
|
- Each task-spec md must point back to the Jira ticket (which is the source of truth) and reference where the work was discovered (originating ticket in this repo).
|
||||||
|
|
||||||
|
## External Systems Are Black Boxes
|
||||||
|
|
||||||
|
External systems (sibling repos, third-party services, parent-suite services like `satellite-provider`) are treated as **black boxes** governed by their published **contract** (OpenAPI spec, contracts/*.md, public schemas, env-var docs).
|
||||||
|
|
||||||
|
- Treat the contract as the ONLY source of truth about an external system. The contract is what you may rely on; the implementation is what you may NOT rely on.
|
||||||
|
- Do NOT investigate, grep, read, browse, or reason about an external system's internal source, internal directory layout, internal database schema, internal config files, persistent volumes, cache contents, log formats, deployment scripts, or any other implementation detail — even when the sibling repo is right there on disk and you could.
|
||||||
|
- The ONE acceptable use of an external repo's source files is to READ ITS CONTRACT (e.g., `../satellite-provider/_docs/02_document/contracts/api/*.md`, an `openapi.yaml`, a `.proto`, a published schema). The contract may live in the sibling repo because that's where the producer documents it — that's fine. Anything OUTSIDE the contract directory is off-limits.
|
||||||
|
- When the external system fails (returns errors, returns malformed data, is unreachable, contradicts its contract): STOP and report it to the user with the exact symptom (status code, error message, missing field, timeout). Do NOT diagnose why by reading the external system's internals. The producer team owns its own diagnosis. The signal is the symptom.
|
||||||
|
- "It works" / "it doesn't work" is the only thing you may conclude about an external system. "It works this way because of X internal mechanism" is forbidden.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
- Internals drift; contracts are stable. Reasoning that depends on internals breaks when the producer refactors.
|
||||||
|
- Investigating internals trains the wrong mental model — agents start "fixing" cross-repo bugs by adapting consumer code to producer quirks instead of flagging the contract gap.
|
||||||
|
- The producer team is the authority on its own system. Bypassing them creates two competing diagnoses and erodes the contract boundary.
|
||||||
|
- Time spent reading external internals is time NOT spent on the actual scope.
|
||||||
|
|
||||||
|
## Concrete examples
|
||||||
|
|
||||||
|
- ✅ Reading `../satellite-provider/_docs/02_document/contracts/api/tile-inventory.md` to learn the inventory POST schema.
|
||||||
|
- ❌ Reading `../satellite-provider/SatelliteProvider.Api/Program.cs` to learn what the inventory endpoint does internally.
|
||||||
|
- ❌ Listing `../satellite-provider/tiles/` to see what tiles are cached.
|
||||||
|
- ❌ Reading `../satellite-provider/.env` to figure out what env vars it expects (read the producer's published `.env.example` or contract doc instead).
|
||||||
|
- ✅ Reporting "satellite-provider returns 500 when I POST a 1-tile inventory for (z=15, x=19308, y=11420)".
|
||||||
|
- ❌ Reporting "satellite-provider returns 500 because its `TileService.GetInventoryAsync` throws when the Postgres `tiles` table is empty".
|
||||||
|
|||||||
@@ -23,3 +23,20 @@ JWT_AUDIENCE=DEV-ONLY-aud-satellite-provider
|
|||||||
# you need to exercise the real GMaps tile-download path, set this to a
|
# you need to exercise the real GMaps tile-download path, set this to a
|
||||||
# valid key.
|
# valid key.
|
||||||
GOOGLE_MAPS_API_KEY=
|
GOOGLE_MAPS_API_KEY=
|
||||||
|
|
||||||
|
# AZ-777: Bearer token C11 sends to satellite-provider as
|
||||||
|
# `Authorization: Bearer <token>`. The token is a JWT signed with
|
||||||
|
# JWT_SECRET above and stamped with the same iss/aud the provider
|
||||||
|
# validates. Mint a dev token with:
|
||||||
|
# python scripts/mint_dev_jwt.py
|
||||||
|
# Production deploys retrieve this from the admin API and rotate per
|
||||||
|
# operator session — never commit a real one.
|
||||||
|
SATELLITE_PROVIDER_API_KEY=PASTE-MINTED-JWT-HERE
|
||||||
|
|
||||||
|
# SECURITY: development-only TLS bypass for the parent-suite
|
||||||
|
# satellite-provider self-signed dev cert. The compose env block sets
|
||||||
|
# SATELLITE_PROVIDER_TLS_INSECURE=1 — it stays inside the Jetson e2e
|
||||||
|
# harness, never in production. Production deploys MUST use a real
|
||||||
|
# CA-issued cert (or your own internal CA) and leave this unset (or
|
||||||
|
# set to "0"). C11 logs a single WARNING at startup whenever the
|
||||||
|
# insecure flag is active so the operator can audit it.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Date**: 2026-05-21 (cycle-3 Step 9 New Task — added AZ-776 (3pt open-loop ESKF composition profile via `c4_pose.enabled` flag, no deps, epic AZ-602) + AZ-777 (5pt Derkachi C6 reference tile cache + FAISS descriptor index from OSM/CARTO basemap, depends on AZ-776, epic AZ-602). Both unblock the 7 currently-`@xfail`-masked Derkachi e2e tests on Jetson; AZ-776 unblocks 5 (AC-1, AC-2, AC-5, AC-6 realtime, AC-6 asap), AZ-777 unblocks the remaining 2 (AC-3 + AZ-699 real-flight verdict). Earlier 2026-05-19 (refreshed late-morning after 11:27 Jetson Tier-2 e2e run for AZ-618 — surfaced a NEW gap: replay-mode `Config` lacks `c6_tile_cache` block, so `build_pre_constructed → _build_c6_descriptor_index → _c6_config` raises `KeyError` for AC-1/2/5/6. Follow-up filed as AZ-687 (2pt) under E-AZ-602 with guard at the bootstrap layer (NOT silent fallback in `_c6_config`). Earlier same-day mid-day after AZ-618 split: per the spec author's own Sizing-note recommendation + user-rule cap on PBI complexity, AZ-618 was split into 6 subtasks AZ-619..AZ-624 in Jira (subtasks of AZ-618; epic AZ-602 stays grandparent). AZ-618 retained at 0pt as the umbrella tracker; aggregate actionable work is 16pt across the subtasks (vs. AZ-618's original 5pt filing — author's "likely a true 8" caveat was understated due to c5_isam2_graph_handle ordering + GPU builder unknowns). Earlier same-day refresh at start of Step-7 rewind for AZ-618 — Step-11 Jetson tier-2 e2e gate identified missing internal product implementation: `runtime_root.main()` does not build the airborne `pre_constructed` infrastructure dict before `compose_root()`; AZ-618 = 5pt cross-cutting follow-up to AZ-591, lives under E-AZ-602; all 12 dep tasks are in `done/`. Earlier 2026-05-16 (cycle-1 completeness-gate post-mortem): AZ-589 + AZ-590 closed Won't Fix — were wrong abstraction (OKVIS v1 `ThreadedKFVio` API doesn't exist in OKVIS2 upstream; VINS-Mono `cpp/vins_mono/upstream/` submodule never existed; the actual production gap is the empty central `_STRATEGY_REGISTRY` affecting EVERY component with a strategy-selecting config field, not just c1_vio); replaced by AZ-591 (cross-cutting compose_root per-binary bootstrap, todo/, 5pt) + AZ-592 (AZ-332 Tier-2 validation bundle, backlog/, 5pt placeholder) + AZ-593 (AZ-333 Tier-2 validation bundle, backlog/, 5pt placeholder); AZ-332 + AZ-333 re-classified in gate report from FAIL to BLOCKED-on-Tier-2 per the original tasks' Implementation Notes deferral handles; earlier same-day after end of cycle-1 gate: AZ-589 + AZ-590 created (now closed); earlier same-day after end of Batch 64: AZ-558 implementation closed — `MavlinkTransport` seam now routes every C8 outbound MAVLink byte; AZ-401 AC-9 + AZ-404 AC-4b unskipped together; encoder helpers extracted to `_outbound_mavlink_payloads.py`; live-mode `compose_root` injection deferred to whichever future batch registers AP/iNav strategies in an airborne binary; earlier 2026-05-14: refreshed at start of Batch 63: AZ-559 closed Won't Fix — gap was illusory; `TileSource.ONBOARD_INGEST` + `TileMetadata.quality_metadata` + `write_tile`'s `FreshnessRejectionError` already cover the AZ-389 mid-flight ingest semantic without any new API; AZ-389 dep restored to AZ-303; earlier same-day after Batch 61: AZ-558 follow-up added — routes C8 outbound encoder bytes through `MavlinkTransport` seam; closes AZ-401 AC-9 deferred during batch 61 due to encoder-side routing not being in the AZ-401 task envelope; earlier same-day after cumulative review batches 52-54: AZ-528 hygiene PBI added for c1_vio strategy facade orchestration-spine 3-way duplication (Medium); earlier same-day after Batch 53: AZ-333 VINS-Mono landed — first c1_vio strategy after the AZ-332 OKVIS2 production-default; consolidation hygiene for the strategy-facade duplication deferred to a post-AZ-334 PBI; earlier same-day after Batch 51: AZ-527 hygiene PBI added from cumulative review batches 49-51 F1; 2026-05-13: AZ-526 hygiene PBI added from cumulative review batches 46-48 F1+F3; same-day refresh after Batch 44 SRP refactor: AZ-317 superseded; AZ-329 + AZ-330 specs rewritten; AZ-523 + AZ-524 audit-trail tickets added; E-C12 epic renamed `Operator Pre-flight Tooling` → `Operator Pre-flight Orchestrator`; earlier same-day refresh: AZ-507 + AZ-508 hygiene PBIs from cumulative review batches 31-33; 2026-05-11: AZ-489 + AZ-490 ADR-010 operator-origin path)
|
**Date**: 2026-05-21 (cycle-3 Step 9 New Task — added AZ-776 (3pt open-loop ESKF composition profile via `c4_pose.enabled` flag, no deps, epic AZ-602) + AZ-777 (5pt Derkachi C6 reference tile cache + FAISS descriptor index from OSM/CARTO basemap, depends on AZ-776, epic AZ-602). Both unblock the 7 currently-`@xfail`-masked Derkachi e2e tests on Jetson; AZ-776 unblocks 5 (AC-1, AC-2, AC-5, AC-6 realtime, AC-6 asap), AZ-777 unblocks the remaining 2 (AC-3 + AZ-699 real-flight verdict). Earlier 2026-05-19 (refreshed late-morning after 11:27 Jetson Tier-2 e2e run for AZ-618 — surfaced a NEW gap: replay-mode `Config` lacks `c6_tile_cache` block, so `build_pre_constructed → _build_c6_descriptor_index → _c6_config` raises `KeyError` for AC-1/2/5/6. Follow-up filed as AZ-687 (2pt) under E-AZ-602 with guard at the bootstrap layer (NOT silent fallback in `_c6_config`). Earlier same-day mid-day after AZ-618 split: per the spec author's own Sizing-note recommendation + user-rule cap on PBI complexity, AZ-618 was split into 6 subtasks AZ-619..AZ-624 in Jira (subtasks of AZ-618; epic AZ-602 stays grandparent). AZ-618 retained at 0pt as the umbrella tracker; aggregate actionable work is 16pt across the subtasks (vs. AZ-618's original 5pt filing — author's "likely a true 8" caveat was understated due to c5_isam2_graph_handle ordering + GPU builder unknowns). Earlier same-day refresh at start of Step-7 rewind for AZ-618 — Step-11 Jetson tier-2 e2e gate identified missing internal product implementation: `runtime_root.main()` does not build the airborne `pre_constructed` infrastructure dict before `compose_root()`; AZ-618 = 5pt cross-cutting follow-up to AZ-591, lives under E-AZ-602; all 12 dep tasks are in `done/`. Earlier 2026-05-16 (cycle-1 completeness-gate post-mortem): AZ-589 + AZ-590 closed Won't Fix — were wrong abstraction (OKVIS v1 `ThreadedKFVio` API doesn't exist in OKVIS2 upstream; VINS-Mono `cpp/vins_mono/upstream/` submodule never existed; the actual production gap is the empty central `_STRATEGY_REGISTRY` affecting EVERY component with a strategy-selecting config field, not just c1_vio); replaced by AZ-591 (cross-cutting compose_root per-binary bootstrap, todo/, 5pt) + AZ-592 (AZ-332 Tier-2 validation bundle, backlog/, 5pt placeholder) + AZ-593 (AZ-333 Tier-2 validation bundle, backlog/, 5pt placeholder); AZ-332 + AZ-333 re-classified in gate report from FAIL to BLOCKED-on-Tier-2 per the original tasks' Implementation Notes deferral handles; earlier same-day after end of cycle-1 gate: AZ-589 + AZ-590 created (now closed); earlier same-day after end of Batch 64: AZ-558 implementation closed — `MavlinkTransport` seam now routes every C8 outbound MAVLink byte; AZ-401 AC-9 + AZ-404 AC-4b unskipped together; encoder helpers extracted to `_outbound_mavlink_payloads.py`; live-mode `compose_root` injection deferred to whichever future batch registers AP/iNav strategies in an airborne binary; earlier 2026-05-14: refreshed at start of Batch 63: AZ-559 closed Won't Fix — gap was illusory; `TileSource.ONBOARD_INGEST` + `TileMetadata.quality_metadata` + `write_tile`'s `FreshnessRejectionError` already cover the AZ-389 mid-flight ingest semantic without any new API; AZ-389 dep restored to AZ-303; earlier same-day after Batch 61: AZ-558 follow-up added — routes C8 outbound encoder bytes through `MavlinkTransport` seam; closes AZ-401 AC-9 deferred during batch 61 due to encoder-side routing not being in the AZ-401 task envelope; earlier same-day after cumulative review batches 52-54: AZ-528 hygiene PBI added for c1_vio strategy facade orchestration-spine 3-way duplication (Medium); earlier same-day after Batch 53: AZ-333 VINS-Mono landed — first c1_vio strategy after the AZ-332 OKVIS2 production-default; consolidation hygiene for the strategy-facade duplication deferred to a post-AZ-334 PBI; earlier same-day after Batch 51: AZ-527 hygiene PBI added from cumulative review batches 49-51 F1; 2026-05-13: AZ-526 hygiene PBI added from cumulative review batches 46-48 F1+F3; same-day refresh after Batch 44 SRP refactor: AZ-317 superseded; AZ-329 + AZ-330 specs rewritten; AZ-523 + AZ-524 audit-trail tickets added; E-C12 epic renamed `Operator Pre-flight Tooling` → `Operator Pre-flight Orchestrator`; earlier same-day refresh: AZ-507 + AZ-508 hygiene PBIs from cumulative review batches 31-33; 2026-05-11: AZ-489 + AZ-490 ADR-010 operator-origin path)
|
||||||
**Total Tasks**: 165 (124 product + 41 blackbox-test) — AZ-317 retained in the table marked SUPERSEDED for audit; AZ-523 (C11 gate removal) + AZ-524 (C12 rename) added as 2 closed audit-trail tasks; AZ-526 = 2pt clock-helper hygiene; AZ-527 = 2pt c2 engine-dim helper hygiene; AZ-528 = 3pt c1_vio facade-spine hygiene; AZ-558 = 3pt MavlinkTransport routing follow-up; AZ-559 closed Won't Fix; AZ-589 + AZ-590 closed Won't Fix (kept in table as 0pt audit-trail rows); AZ-591 = 5pt cross-cutting compose_root bootstrap (todo/); AZ-592 = 5pt OKVIS2 Tier-2 placeholder (backlog/); AZ-593 = 5pt VINS-Mono Tier-2 placeholder (backlog/); AZ-618 = 0pt umbrella (split into AZ-619..AZ-624 on 2026-05-19); AZ-619..AZ-624 = 6 subtasks of AZ-618 covering Phase A..F of the airborne `pre_constructed` assembly, summing to 16pt actionable work; AZ-687 = 2pt replay-mode guard follow-up surfaced by AZ-618 Tier-2 run on 2026-05-19
|
**Total Tasks**: 165 (124 product + 41 blackbox-test) — AZ-317 retained in the table marked SUPERSEDED for audit; AZ-523 (C11 gate removal) + AZ-524 (C12 rename) added as 2 closed audit-trail tasks; AZ-526 = 2pt clock-helper hygiene; AZ-527 = 2pt c2 engine-dim helper hygiene; AZ-528 = 3pt c1_vio facade-spine hygiene; AZ-558 = 3pt MavlinkTransport routing follow-up; AZ-559 closed Won't Fix; AZ-589 + AZ-590 closed Won't Fix (kept in table as 0pt audit-trail rows); AZ-591 = 5pt cross-cutting compose_root bootstrap (todo/); AZ-592 = 5pt OKVIS2 Tier-2 placeholder (backlog/); AZ-593 = 5pt VINS-Mono Tier-2 placeholder (backlog/); AZ-618 = 0pt umbrella (split into AZ-619..AZ-624 on 2026-05-19); AZ-619..AZ-624 = 6 subtasks of AZ-618 covering Phase A..F of the airborne `pre_constructed` assembly, summing to 16pt actionable work; AZ-687 = 2pt replay-mode guard follow-up surfaced by AZ-618 Tier-2 run on 2026-05-19
|
||||||
**Total Complexity Points**: 543 (410 product + 133 blackbox-test) — +3pt AZ-776 + 5pt AZ-777 added 2026-05-21 — AZ-523 = 3pt, AZ-524 = 2pt, AZ-526 = 2pt, AZ-527 = 2pt, AZ-528 = 3pt, AZ-558 = 3pt, AZ-589 + AZ-590 retained at 5pt each but closed Won't Fix (treated as 0 effective pts going forward), AZ-591 = 5pt, AZ-592 = 5pt placeholder, AZ-593 = 5pt placeholder, AZ-618 = 0pt umbrella post-split, AZ-619 = 2pt, AZ-620 = 3pt, AZ-621 = 3pt, AZ-622 = 3pt, AZ-623 = 3pt, AZ-624 = 2pt, AZ-687 = 2pt
|
**Total Complexity Points**: 546 (413 product + 133 blackbox-test) — +3pt AZ-776 + 8pt AZ-777 (5→8 override 2026-05-21 cycle-3 batch 104; see `_docs/_process_leftovers/2026-05-21_az777_complexity_override.md` for rationale + the spec refresh that pulled e2e-runner wiring + C11 contract adapt + Derkachi catalog seed + fixture replacement + un-xfail into one ticket) — AZ-523 = 3pt, AZ-524 = 2pt, AZ-526 = 2pt, AZ-527 = 2pt, AZ-528 = 3pt, AZ-558 = 3pt, AZ-589 + AZ-590 retained at 5pt each but closed Won't Fix (treated as 0 effective pts going forward), AZ-591 = 5pt, AZ-592 = 5pt placeholder, AZ-593 = 5pt placeholder, AZ-618 = 0pt umbrella post-split, AZ-619 = 2pt, AZ-620 = 3pt, AZ-621 = 3pt, AZ-622 = 3pt, AZ-623 = 3pt, AZ-624 = 2pt, AZ-687 = 2pt
|
||||||
|
|
||||||
Dependencies columns list only the tracker-ID portion (descriptive tail
|
Dependencies columns list only the tracker-ID portion (descriptive tail
|
||||||
text in each task spec is omitted here for table-readability). The
|
text in each task spec is omitted here for table-readability). The
|
||||||
@@ -184,7 +184,10 @@ are all declared and documented below under **Cycle Check**.
|
|||||||
| AZ-701 | T5: HTTP Replay API service (POST tlog+video, return GPS fixes + map) | 5 | AZ-699, AZ-700 | AZ-696 |
|
| AZ-701 | T5: HTTP Replay API service (POST tlog+video, return GPS fixes + map) | 5 | AZ-699, AZ-700 | AZ-696 |
|
||||||
| AZ-702 | T6: Topotek KHP20S30 camera calibration (factory-sheet approximation) | 1 | None | AZ-696 |
|
| AZ-702 | T6: Topotek KHP20S30 camera calibration (factory-sheet approximation) | 1 | None | AZ-696 |
|
||||||
| AZ-776 | Open-loop ESKF composition profile (c4_pose.enabled flag) | 3 | None | AZ-602 |
|
| AZ-776 | Open-loop ESKF composition profile (c4_pose.enabled flag) | 3 | None | AZ-602 |
|
||||||
| AZ-777 | Derkachi C6 reference tile cache + FAISS descriptor index (OSM/CARTO) | 5 | AZ-776 | AZ-602 |
|
| AZ-777 | Derkachi e2e: wire EXISTING parent-suite satellite-provider into operator pre-flight fixture | 8 (override) | AZ-776 | AZ-602 |
|
||||||
|
| AZ-835 | E2E real-flight validation Epic: raw (tlog, video) → route-driven SP seeding → verdict | Epic (~17 child SP) | AZ-777 Phase 1 (reused); AZ-405; AZ-699; AZ-696; AZ-702 | (umbrella) |
|
||||||
|
| AZ-836 | C1: TlogRouteExtractor — active-segment trim + DP coarsen tlog GPS to ≤N waypoints | 3 | AZ-697, AZ-279 | AZ-835 |
|
||||||
|
| AZ-838 | C2: SatelliteProviderRouteClient + seed_route.py CLI — POST RouteSpec to SP, poll mapsReady | 3 | AZ-836; AZ-777 Phase 1; AZ-809 (soft) | AZ-835 |
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# TlogRouteExtractor
|
||||||
|
|
||||||
|
**Task**: AZ-836_tlog_route_extractor
|
||||||
|
**Name**: TlogRouteExtractor: extract active flight segment + coarsen tlog GPS to ≤N waypoints (AZ-835 C1)
|
||||||
|
**Description**: First building block of Epic AZ-835. Pure, testable function that consumes a `.tlog` binary and returns a `RouteSpec` (≤ N waypoints + suggested per-waypoint coverage radius) suitable for posting to satellite-provider's `POST /api/satellite/route` endpoint (consumed by AZ-835 C2 / AZ-838).
|
||||||
|
**Complexity**: 3 SP
|
||||||
|
**Dependencies**: AZ-697 (`load_tlog_ground_truth` — done); AZ-279 (WGS converter — done); AZ-835 (parent Epic)
|
||||||
|
**Component**: `src/gps_denied_onboard/replay_input/tlog_route.py` (new module under `replay_input/`)
|
||||||
|
**Tracker**: AZ-836 (https://denyspopov.atlassian.net/browse/AZ-836)
|
||||||
|
**Parent Epic**: AZ-835
|
||||||
|
|
||||||
|
Jira AZ-836 is the authoritative spec; this file is the in-workspace mirror.
|
||||||
|
|
||||||
|
## Public surface
|
||||||
|
|
||||||
|
```python
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RouteSpec:
|
||||||
|
waypoints: tuple[tuple[float, float], ...] # (lat, lon), 1..max_waypoints
|
||||||
|
suggested_region_size_meters: float # per-waypoint coverage radius
|
||||||
|
source_tlog: Path # provenance
|
||||||
|
source_segment: tuple[int, int] # (start_idx, end_idx) into tlog GPS rows
|
||||||
|
total_distance_meters: float # along-track distance of active segment
|
||||||
|
|
||||||
|
class RouteExtractionError(ReplayInputAdapterError): ...
|
||||||
|
|
||||||
|
def extract_route_from_tlog(
|
||||||
|
tlog: Path,
|
||||||
|
*,
|
||||||
|
max_waypoints: int = 10,
|
||||||
|
min_takeoff_speed_m_s: float = 2.0,
|
||||||
|
min_takeoff_altitude_agl_m: float = 5.0,
|
||||||
|
douglas_peucker_tolerance_m: float | None = None, # auto-computed if None
|
||||||
|
region_size_meters: float = 500.0,
|
||||||
|
) -> RouteSpec: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Reuses `replay_input.tlog_ground_truth.load_tlog_ground_truth()` for GPS extraction — no MAVLink re-parsing.
|
||||||
|
|
||||||
|
## Active-segment detection
|
||||||
|
|
||||||
|
Trim leading + trailing rows where horizontal speed < `min_takeoff_speed_m_s` AND altitude AGL < `min_takeoff_altitude_agl_m`. Both thresholds configurable. If trimmed segment has < 2 fixes, raise `RouteExtractionError` with the explicit threshold values — no silent fallback to the full tlog.
|
||||||
|
|
||||||
|
## Coarsening
|
||||||
|
|
||||||
|
Douglas-Peucker in WGS84 with great-circle distance metric. Use the existing `helpers.wgs_converter` or `helpers.gps_compare` meter conversion — do NOT reimplement (check both first; pick whichever has the right primitive).
|
||||||
|
|
||||||
|
When `douglas_peucker_tolerance_m is None`, auto-compute by binary-search over the tolerance until `len(result) <= max_waypoints`. Halt at convergence (delta < 1 m) or 32 iterations.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- `max_waypoints >= 1` (raise `ValueError`).
|
||||||
|
- `region_size_meters > 0` (raise `ValueError`).
|
||||||
|
- At least 1 fix from `GLOBAL_POSITION_INT` (preferred) or `GPS_RAW_INT` (fallback); if neither, `RouteExtractionError` referencing missing message types (mirrors AZ-697).
|
||||||
|
- Missing tlog file → `RouteExtractionError` (not bare `FileNotFoundError`) so callers can catch one error class.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
| # | Criterion |
|
||||||
|
|---|-----------|
|
||||||
|
| AC-1 | Real Derkachi tlog → RouteSpec with `len(waypoints) <= 10`; every waypoint inside lat 50.0808..50.0832, lon 36.1070..36.1134 |
|
||||||
|
| AC-2 | Active-segment trim filters pre-takeoff stationary frames (synthetic 5+ stationary leading fixes → `source_segment[0] > 0`) |
|
||||||
|
| AC-3 | `max_waypoints=2` → exactly 2 waypoints |
|
||||||
|
| AC-4 | `max_waypoints=100` on N<100 tlog → N waypoints (no coarsening below natural fix count) |
|
||||||
|
| AC-5 | Missing tlog → `RouteExtractionError` with path; not `FileNotFoundError` |
|
||||||
|
| AC-6 | Tlog with no GPS → `RouteExtractionError` naming missing message types |
|
||||||
|
| AC-7 | `RouteSpec` is `frozen=True`, `slots=True`, all provenance fields populated |
|
||||||
|
| AC-8 | Auto-tolerance binary-search converges within 32 iters on a 200-fix synthetic trajectory |
|
||||||
|
| AC-9 | No I/O beyond tlog read; logging at DEBUG only |
|
||||||
|
| AC-10 | Unit tests cover: Derkachi happy path, small/large max_waypoints, missing tlog, missing GPS, custom DP tolerance, custom region size, synthetic stationary-leading trim |
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Posting to satellite-provider (AZ-838 / C2)
|
||||||
|
- Route visualization on a map (future, AZ-700-style)
|
||||||
|
- Multi-tlog aggregation
|
||||||
|
- Live-stream tlog ingestion
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Parent Epic: AZ-835 — https://denyspopov.atlassian.net/browse/AZ-835
|
||||||
|
- Reference tlog: `_docs/00_problem/input_data/flight_derkachi/derkachi.tlog`
|
||||||
|
- Reuse: `src/gps_denied_onboard/replay_input/tlog_ground_truth.py` (AZ-697), `src/gps_denied_onboard/helpers/gps_compare.py`
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# SatelliteProviderRouteClient + seed_route.py CLI
|
||||||
|
|
||||||
|
**Task**: AZ-838_satellite_provider_route_client
|
||||||
|
**Name**: SatelliteProviderRouteClient + seed_route.py CLI: POST tlog-derived route to satellite-provider (AZ-835 C2)
|
||||||
|
**Description**: Second building block of Epic AZ-835. Consumer-side HTTP client + CLI wrapper that takes a `RouteSpec` (from AZ-836 / C1) and registers it with satellite-provider's `POST /api/satellite/route` endpoint, polls until `mapsReady=true`, and returns the inventory size for downstream consumption.
|
||||||
|
**Complexity**: 3 SP
|
||||||
|
**Dependencies**: AZ-836 (C1, RouteSpec dataclass + extractor — hard code dep); AZ-777 Phase 1 (existing satellite-provider HTTP plumbing patterns + JWT handling — done); AZ-809 (Route API validation — SOFT prereq, client pre-emptively validates so it's correct without it); AZ-835 (parent Epic)
|
||||||
|
**Component**: new `src/gps_denied_onboard/satellite_provider/route_client.py` + new CLI `tests/fixtures/derkachi_c6/seed_route.py`
|
||||||
|
**Tracker**: AZ-838 (https://denyspopov.atlassian.net/browse/AZ-838)
|
||||||
|
**Parent Epic**: AZ-835
|
||||||
|
|
||||||
|
Jira AZ-838 is the authoritative spec; this file is the in-workspace mirror.
|
||||||
|
|
||||||
|
## Public surface
|
||||||
|
|
||||||
|
```python
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from gps_denied_onboard.replay_input.tlog_route import RouteSpec # AZ-836
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RouteSeedResult:
|
||||||
|
route_id: uuid.UUID
|
||||||
|
terminal_status: str
|
||||||
|
maps_ready: bool
|
||||||
|
tile_count: int
|
||||||
|
elapsed_ms: int
|
||||||
|
submitted_payload_sha256: str
|
||||||
|
|
||||||
|
class SatelliteProviderRouteError(Exception): ...
|
||||||
|
class RouteValidationError(SatelliteProviderRouteError): ... # 4xx + ProblemDetails
|
||||||
|
class RouteTransientError(SatelliteProviderRouteError): ... # 5xx / network / timeout
|
||||||
|
class RouteTerminalFailureError(SatelliteProviderRouteError): ... # mapsReady never reached
|
||||||
|
|
||||||
|
class SatelliteProviderRouteClient:
|
||||||
|
def __init__(self, base_url: str, jwt: str, *, tls_insecure: bool = False,
|
||||||
|
request_timeout_s: float = 30.0, poll_interval_s: float = 5.0,
|
||||||
|
poll_max_attempts: int = 60): ...
|
||||||
|
def seed_route(self, spec: RouteSpec, *, name: str | None = None) -> RouteSeedResult: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Wire shape
|
||||||
|
|
||||||
|
No formal Route API contract doc exists in `../satellite-provider/_docs/02_document/contracts/api/` as of 2026-05-22. DTOs are the source of truth:
|
||||||
|
|
||||||
|
- `../satellite-provider/SatelliteProvider.Common/DTO/CreateRouteRequest.cs` (top-level)
|
||||||
|
- `../satellite-provider/SatelliteProvider.Common/DTO/RoutePoint.cs` (`[JsonPropertyName("lat")] Latitude`, `[JsonPropertyName("lon")] Longitude` — input/output naming asymmetry flagged in AZ-809 AC-10; consume `lat`/`lon` in the JSON)
|
||||||
|
- `../satellite-provider/SatelliteProvider.Common/DTO/GeoPoint.cs` (nested geofence point)
|
||||||
|
|
||||||
|
Probe 2026-05-22: 2-point route + `requestMaps=true` completes end-to-end in ~15 s.
|
||||||
|
|
||||||
|
## Behaviour
|
||||||
|
|
||||||
|
- **Pre-emptive validation** against AZ-809 rules — surface as `RouteValidationError` BEFORE HTTP POST:
|
||||||
|
- `points` non-empty AND `len(points) <= 100`
|
||||||
|
- `id` non-zero Guid
|
||||||
|
- `regionSizeMeters > 0` AND `<= 10000`
|
||||||
|
- `zoomLevel` in 15..18 (per AZ-777 Phase 2 bbox config)
|
||||||
|
- Each point's `lat` in -90..90, `lon` in -180..180
|
||||||
|
- **Submit** `POST /api/satellite/route` with `requestMaps=true`, `createTilesZip=false`.
|
||||||
|
- **Poll** `GET /api/satellite/route/{id}` every `poll_interval_s` up to `poll_max_attempts` until `mapsReady=true` OR terminal failure. Log cadence at INFO.
|
||||||
|
- **Return** `RouteSeedResult`; `tile_count` from a final `POST /api/satellite/tiles/inventory` enumerating the route's tile coverage (computed locally from waypoints + `regionSizeMeters`).
|
||||||
|
- **Raise** `RouteTerminalFailureError` on terminal failure (`.detail` = SP response JSON).
|
||||||
|
- **Raise** `RouteTransientError` on 5xx / network / timeout (`__cause__` = underlying `httpx` exception).
|
||||||
|
- **Raise** `RouteValidationError` on 4xx; parse RFC 7807 `errors` dict into `field_errors`.
|
||||||
|
|
||||||
|
## CLI (`tests/fixtures/derkachi_c6/seed_route.py`)
|
||||||
|
|
||||||
|
Mirrors `seed_region.py` (AZ-777 Phase 2):
|
||||||
|
|
||||||
|
- Env: `SATELLITE_PROVIDER_URL`, `SATELLITE_PROVIDER_API_KEY`, `SATELLITE_PROVIDER_TLS_INSECURE`, optional `--auto-mint-jwt` (uses `scripts/mint_dev_jwt.py`)
|
||||||
|
- Required: `--tlog <path>` (delegates to AZ-836's `extract_route_from_tlog`)
|
||||||
|
- Optional: `--max-waypoints` (10), `--region-size-meters` (500), `--name`, `--output-summary <path>`, `--dry-run`
|
||||||
|
- Exit codes: 0 success, 71 config malformed, 72 missing env, 73 SP unreachable, 74 4xx, 75 5xx / terminal failure, 76 inventory verification mismatch
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
| # | Criterion |
|
||||||
|
|---|-----------|
|
||||||
|
| AC-1 | POSTs wire shape exactly per `CreateRouteRequest.cs` + `RoutePoint.cs` + `GeoPoint.cs` |
|
||||||
|
| AC-2 | Polls `GET /api/satellite/route/{id}` until `mapsReady=true` OR terminal failure; respects `poll_max_attempts` + `poll_interval_s` |
|
||||||
|
| AC-3 | 4xx + RFC 7807 ProblemDetails → `RouteValidationError`; `field_errors` populated from `errors` dict |
|
||||||
|
| AC-4 | 5xx / network / timeout → `RouteTransientError`; `__cause__` = underlying `httpx` exc |
|
||||||
|
| AC-5 | Terminal failure → `RouteTerminalFailureError`; `.detail` = SP response JSON |
|
||||||
|
| AC-6 | Pre-emptive validation rejects (BEFORE HTTP POST): empty `points`, >100 `points`, missing/zero `id`, missing/zero `regionSizeMeters`, OOR `zoomLevel`, OOR lat/lon |
|
||||||
|
| AC-7 | `seed_route.py --dry-run --tlog <derkachi.tlog>`: extracts route, prints planned payload + sha256, exit 0, no HTTP |
|
||||||
|
| AC-8 | `seed_route.py --tlog <derkachi.tlog>` against Jetson SP: exit 0, prints `RouteSeedResult`, optional summary JSON |
|
||||||
|
| AC-9 | Unit tests (mocked HTTPX): happy path, 400+ProblemDetails, 500 transient, terminal failure, timeout, dry-run, missing env, all pre-emptive validation cases |
|
||||||
|
| AC-10 | Integration test gated by `RUN_E2E=1` + `SATELLITE_PROVIDER_URL`: Derkachi route seeded, `tile_count > 0`, `maps_ready=True` |
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- FAISS index from seeded tiles (AZ-835 C3 / C5)
|
||||||
|
- C6 cache population (AZ-835 C3 — new `operator_pre_flight_setup` fixture)
|
||||||
|
- Modifying satellite-provider source (Route API consumed as-is)
|
||||||
|
- Multi-route batching (one RouteSpec → one POST)
|
||||||
|
- Authentication beyond existing JWT pattern (AZ-494)
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Parent Epic: AZ-835 — https://denyspopov.atlassian.net/browse/AZ-835
|
||||||
|
- Sibling: AZ-836 (C1) — RouteSpec source
|
||||||
|
- Mirror CLI: `tests/fixtures/derkachi_c6/seed_region.py` (AZ-777 Phase 2)
|
||||||
|
- HTTP patterns: `src/gps_denied_onboard/components/c11_tile_manager/tile_downloader.py` (AZ-316/777 Phase 1)
|
||||||
|
- DTOs (in `../satellite-provider/`): `SatelliteProvider.Common/DTO/{CreateRouteRequest,RoutePoint,GeoPoint}.cs`
|
||||||
|
- Soft prereq: AZ-809 (Route API validation in satellite-provider)
|
||||||
@@ -1,193 +1,209 @@
|
|||||||
# Derkachi C6 reference tile cache + descriptor index (OSM/CARTO basemap)
|
# Derkachi e2e: wire EXISTING parent-suite satellite-provider into the operator pre-flight fixture
|
||||||
|
|
||||||
**Task**: AZ-777_derkachi_c6_reference_fixture
|
**Task**: AZ-777_derkachi_c6_reference_fixture
|
||||||
**Name**: Build the C6 reference tile cache + FAISS descriptor index for the Derkachi flight bbox so the full-protocol C1+C2+C3+C4+C5 pipeline can produce satellite anchors during e2e replay
|
**Name**: Drive the production C10/C11 pre-flight pipeline against the parent-suite `satellite-provider` .NET service ALREADY running in the Jetson e2e harness so the Derkachi clip produces a real FAISS-anchored C4/C5 satellite-fix loop end-to-end
|
||||||
**Description**: Add a reproducible build script that downloads OSM/CARTO basemap tiles for the Derkachi flight bbox (approx 50.05–50.15 lat, 36.05–36.15 lon), pre-computes feature descriptors via the same C7 backbone the airborne binary uses (DINOv2 or the configured VPR backbone), populates the C6 tile store + FAISS HNSW index, and integrates them into the e2e replay harness. Unblocks the two remaining `@xfail`-masked Derkachi tests on Jetson (`test_ac3_within_100m_80pct_of_ticks` and `test_az699_real_flight_validation_emits_verdict_and_report`) and produces the first honest AZ-699 accuracy verdict.
|
**Description**: The Jetson e2e harness already runs the real `satellite-provider` .NET 8 service (lineage AZ-688 / AZ-691 / AZ-692, services `satellite-provider` + `satellite-provider-postgres` in `docker-compose.test.jetson.yml`), but the e2e-runner still points its `SATELLITE_PROVIDER_URL` at the legacy `mock-sat` fixture and the placeholder `operator_pre_flight_setup` fixture never drives the C10/C11 pipeline. Compounding this, C11's `HttpTileDownloader` path constants (`_LIST_PATH=/api/satellite/tiles`, `_GET_PATH=/api/satellite/tiles/{tile_id}`) do not match the real satellite-provider API surface (`POST /api/satellite/tiles/inventory` for LIST, `GET /tiles/{z}/{x}/{y}` for tile fetch). This task wires the existing service into the e2e-runner, adapts C11 to the real contract, seeds the Derkachi-bbox tile catalog via `POST /api/satellite/request`, replaces the placeholder fixture with a real C10+C11 driver, and un-xfails the Tier-2 Derkachi + AZ-699 verdict tests.
|
||||||
**Complexity**: 5 points
|
**Complexity**: 8 points (explicit override of the standard 5-pt PBI cap — see decision log entry 2026-05-21 + spec refresh note at `_docs/_process_leftovers/2026-05-21_az777_complexity_override.md`; scope reconciled with reality 2026-05-21 during cycle-3 batch 104. Single-ticket containment preserved — the four sub-deliverables only deliver demo-confidence value when shipped together.)
|
||||||
**Dependencies**: AZ-776_eskf_open_loop_composition_profile
|
**Dependencies**: AZ-776 done (eskf open-loop composition profile unblocks the replay graph for Derkachi); relies on prior compose-side work AZ-688 / AZ-691 / AZ-692 (closed in Jira without local task spec files — the `satellite-provider` + `satellite-provider-postgres` services + `.env.test.example` are already present)
|
||||||
**Component**: c6_tile_cache / e2e fixtures / input_data
|
**Component**: e2e fixtures / c6_tile_cache / c10_provisioning / c11_tile_manager / docker compose
|
||||||
**Tracker**: AZ-777
|
**Tracker**: AZ-777
|
||||||
**Epic**: AZ-602
|
**Epic**: AZ-602
|
||||||
|
|
||||||
## Problem
|
## Problem
|
||||||
|
|
||||||
The Derkachi e2e fixture
|
The Derkachi e2e fixture (`_docs/00_problem/input_data/flight_derkachi/`) ships real flight inputs but DOES NOT ship the populated C6 tile cache + FAISS descriptor index the replay protocol requires (`replay_protocol.md` Invariant 12). Three architectural gaps stop the full C1+C2+C3+C4+C5 pipeline from running against Derkachi today:
|
||||||
(`_docs/00_problem/input_data/flight_derkachi/`) ships the real
|
|
||||||
flight inputs (video, tlog, IMU, camera calibration) but DOES NOT
|
|
||||||
ship the C6 tile-cache artifacts that the replay protocol requires
|
|
||||||
the operator's pre-flight C10 stage to produce:
|
|
||||||
|
|
||||||
- `c6_tile_store` — persistent JPEG tiles covering the flight area at the chosen zoom levels
|
1. **`e2e-runner` still points at `mock-sat`.** In `docker-compose.test.jetson.yml` the `e2e-runner` env block has `SATELLITE_PROVIDER_URL: http://mock-sat:5100` even though `mock-sat` is no longer defined in that file and the real `satellite-provider` service (https://satellite-provider:8080) IS defined right below.
|
||||||
- `c6_descriptor_index` — FAISS index of VPR-backbone descriptors over those tiles
|
2. **C11 contract drift.** `c11_tile_manager/tile_downloader.py:61-62` defines `_LIST_PATH = /api/satellite/tiles` and `_GET_PATH = /api/satellite/tiles`. The real satellite-provider exposes `POST /api/satellite/tiles/inventory` (bulk lookup by z/x/y or `locationHashes`) and `GET /tiles/{z:int}/{x:int}/{y:int}` (slippy-map tile fetch) — different paths, different methods, different schemas (`Program.cs:187-209`).
|
||||||
|
3. **`operator_pre_flight_setup` is a placeholder.** The fixture at `tests/e2e/replay/conftest.py` (lines 293-310) `mkdir`s an empty `operator_cache` directory and yields. It does NOT drive C11 download or C10 descriptor-batcher; it does NOT populate C6. The fixture's docstring explicitly calls itself "a stub" pending this ticket.
|
||||||
|
|
||||||
Without these artifacts:
|
Production architecture (per `architecture.md` Principle #5 + the C10/C11 descriptions) requires:
|
||||||
|
|
||||||
- C2 VPR has no haystack to look up against — `c2_vpr.lookup` returns empty.
|
- C10 does NOT touch satellite-provider — tile network I/O lives in C11.
|
||||||
- C3 matcher has nothing to match against (depends on C2 candidates).
|
- C11 `HttpTileDownloader` is the production path: authenticated GETs against the parent-suite `satellite-provider`.
|
||||||
- C4 pose has no anchors — cannot estimate satellite-frame pose.
|
- `satellite-provider` owns OSM/CARTO tile network I/O + license attribution + multi-flight voting layer — the onboard companion is read-only against it (via C11) during pre-flight and read-only against C6 during flight.
|
||||||
- C5 state has no anchors to fuse — runs open-loop on VIO only.
|
- `mock-sat` is fully obsolete on Jetson (D-PROJ-2 / `POST /api/satellite/upload` shipped — verified at `Program.cs:211`). Tier-1 (`docker-compose.test.yml`) is deprecated per `_docs/02_document/tests/environment.md` 2026-05-20 active policy and is OUT OF SCOPE.
|
||||||
|
|
||||||
When `c5_state.strategy = gtsam_isam2` (the default that AZ-699's e2e
|
|
||||||
exercises), the composition reaches the per-frame loop but
|
|
||||||
`iSAM2.update` crashes at frame 1 with:
|
|
||||||
|
|
||||||
```
|
|
||||||
EstimatorFatalError: compute_marginals failed: Attempting to at the
|
|
||||||
key 'x2', which does not exist in the Values.
|
|
||||||
```
|
|
||||||
|
|
||||||
— because no C4 anchor was ever inserted (C2/C3/C4 have nothing to
|
|
||||||
match against).
|
|
||||||
|
|
||||||
AZ-776 (sibling, prerequisite) makes the open-loop C1+C5(ESKF)
|
|
||||||
composition runnable, but that path skips C2–C4 entirely and accepts
|
|
||||||
unbounded drift. To validate the FULL protocol-compliant pipeline
|
|
||||||
against Derkachi — i.e. AC-3 (`≤100 m for 80 % of ticks`) and the
|
|
||||||
AZ-699 horizontal-error verdict — we need real C6 fixtures.
|
|
||||||
|
|
||||||
The replay protocol (`replay_protocol.md` line 214) explicitly states
|
|
||||||
"`BUILD_FAISS_INDEX` is ON in the airborne binary (live and replay
|
|
||||||
alike). C2 in replay queries the **real** C6 `FaissDescriptorIndex`,
|
|
||||||
populated by the pre-flight C10 build. This is the architectural
|
|
||||||
change vs. v1.0.0 of this contract." We have no such build for
|
|
||||||
Derkachi.
|
|
||||||
|
|
||||||
## Outcome
|
## Outcome
|
||||||
|
|
||||||
- A reproducible build script under `scripts/` produces the C6 artifacts (`tile_store` + `descriptor_index`) given the Derkachi bbox + zoom levels + camera calibration, deterministically on a clean checkout, in under 30 minutes on a developer workstation.
|
- The e2e-runner in `docker-compose.test.jetson.yml` consumes the existing real `satellite-provider` service over `https://satellite-provider:8080` with a self-signed dev cert and a static Bearer `service_api_key` token. `mock-sat` references removed.
|
||||||
- Reference imagery source is OSM-tile-server-distributed basemap (CARTO Voyager or equivalent CC-BY-licensed source). Each tile carries the source URL + license attribution in its metadata sidecar.
|
- C11 `HttpTileDownloader._LIST_PATH` / `_GET_PATH` adapted to the real satellite-provider API surface (`POST /api/satellite/tiles/inventory` for LIST; `GET /tiles/{z}/{x}/{y}` for tile fetch), with the consumer code in `_do_enumerate` + `_download_one_tile` updated to match. All existing C11 unit tests in `tests/unit/c11_tile_manager/` re-greened against the new contract.
|
||||||
- The Derkachi fixture directory documents the build invocation; tiles + index are EITHER committed to the repo (if total size ≤ 100 MB) OR built on-demand from the script (if larger) — decision recorded in the fixture README.
|
- `satellite-provider`'s tile catalog is seeded with the Derkachi bbox (≈50.05–50.15 lat, 36.05–36.15 lon, zoom 15–18) via `POST /api/satellite/request`. Imagery source: **Google Maps satellite layer** (`mt0..mt3.google.com/vt/lyrs=s`) — verified via 2026-05-22 black-box probe of the running satellite-provider. NOTE: this was originally specced as CARTO Voyager Basemap (CC-BY-3.0); the spec was amended 2026-05-22 after the probe revealed the actual upstream is Google Maps governed by Google Maps Platform Terms of Service. Dev/research use only; production deployment requires Google Maps Platform licensing review OR migration to a true CC-BY source on the satellite-provider side (parent-suite ticket TBD).
|
||||||
- `tests/e2e/replay/conftest.py`'s `operator_pre_flight_setup` fixture is replaced (or extended) to mount the prebuilt artifacts into the e2e-runner container. The mock-suite-sat-service stub is retired for the C6-served paths (it remains for the C12 operator-workflow AC-8).
|
- `tests/e2e/replay/conftest.py::operator_pre_flight_setup` replaced by a real fixture that drives adapted C11 + C10 against the seeded catalog and yields a `PopulatedC6Cache` dataclass mounted via named volumes that survive across pytest sessions.
|
||||||
- After this task ships (with AZ-776), un-xfail `test_ac3_within_100m_80pct_of_ticks` (`test_derkachi_1min.py` line 174) AND `test_az699_real_flight_validation_emits_verdict_and_report` (`test_derkachi_real_tlog.py` line 174); both pass on the Jetson harness.
|
- AC-3 (`test_ac3_within_100m_80pct_of_ticks` in `tests/e2e/replay/test_derkachi_1min.py`) un-xfails on Tier-2 Jetson with ≥ 80 % of ticks within 100 m of ground truth.
|
||||||
- The first honest AZ-699 verdict lands at `_docs/06_metrics/real_flight_validation_<YYYY-MM-DD>.md` with the full horizontal-error distribution. Whether the verdict is PASS or FAIL is the honest finding — this task's success is that the verdict is *produced* against the real pipeline, not that it is necessarily green.
|
- AZ-699 verdict test (`test_az699_real_flight_validation_emits_verdict_and_report`) un-xfails and produces the first honest horizontal-error distribution report at `_docs/06_metrics/real_flight_validation_<YYYY-MM-DD>.md`.
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
### Included
|
### Included
|
||||||
|
|
||||||
- `scripts/build_derkachi_c6_fixture.py` (or equivalent module under `e2e/fixtures/derkachi_c6/`): reproducible build pipeline that:
|
**Phase 1 — wire e2e-runner against existing satellite-provider + C11 contract adaptation**
|
||||||
- Reads the Derkachi bbox + zoom levels from a small YAML config (`tests/fixtures/derkachi_c6/bbox.yaml`).
|
|
||||||
- Downloads OSM/CARTO basemap tiles into `<output>/tiles/{zoom}/{x}/{y}.jpg` mirroring `satellite-provider`'s on-disk layout (per architecture principle #5).
|
- `docker-compose.test.jetson.yml` (only the `e2e-runner` service block changes; the existing `satellite-provider` + `satellite-provider-postgres` blocks are unchanged):
|
||||||
- Computes per-tile descriptors via the same C7 backbone the airborne binary uses (configurable; defaults to whatever `config.components.c2_vpr.strategy`'s feature dimension is — e.g. UltraVPR or NetVLAD).
|
- Switch e2e-runner `SATELLITE_PROVIDER_URL: http://mock-sat:5100` → `SATELLITE_PROVIDER_URL: https://satellite-provider:8080`.
|
||||||
- Builds a FAISS HNSW index over the descriptors, writes via `faiss.write_index` + atomicwrites + SHA-256 content-hash gate (per D-C10-3).
|
- Add `SATELLITE_PROVIDER_TLS_INSECURE: "1"` env var (development-only) so requests accepts the self-signed dev cert. Loud warning + documentation per Risk 2.
|
||||||
- Emits a manifest JSON recording tile count, bbox, zoom levels, backbone, descriptor dimension, FAISS index parameters, source URL template, license, and the SHA-256 of every artifact.
|
- Add `SATELLITE_PROVIDER_API_KEY: ${SATELLITE_PROVIDER_API_KEY}` env sourced from `.env.test` (matches existing `JWT_SECRET` pattern; `.env.test.example` already covers JWT_*, this one extends it with one new variable).
|
||||||
- `tests/fixtures/derkachi_c6/bbox.yaml`: the bbox + zoom + backbone config consumed by the build script. Committed.
|
- Add `e2e-runner.depends_on.satellite-provider: { condition: service_healthy }`.
|
||||||
- `tests/fixtures/derkachi_c6/README.md`: how to rebuild + license attribution + estimated artifact size.
|
- Remove any residual `mock-sat` reference from the `e2e-runner` env block (the service itself is already gone from the file).
|
||||||
- Build the artifacts once, decide commit vs on-demand:
|
- **C11 contract adaptation** (in `src/gps_denied_onboard/components/c11_tile_manager/tile_downloader.py`):
|
||||||
- If total size ≤ 100 MB → commit to `_docs/00_problem/input_data/flight_derkachi/c6_cache/` (under LFS).
|
- Change `_LIST_PATH = "/api/satellite/tiles"` → `_LIST_PATH = "/api/satellite/tiles/inventory"` and switch `_do_enumerate` from GET-with-query-params to POST-with-JSON-body per AZ-505 / `tile-inventory.md` v1.0.0 (body: `{tiles: [{tileZoom, tileX, tileY}, ...]}` OR `{locationHashes: [...]}`; response order matches request order with `present: true|false`).
|
||||||
- If > 100 MB → keep build-on-demand only, document the build invocation in the fixture README, and add a `scripts/run-tests-jetson.sh` pre-step that builds if absent.
|
- Change `_GET_PATH = "/api/satellite/tiles"` → `_GET_PATH = "/tiles"` and adjust `_download_one_tile` to build `/tiles/{z}/{x}/{y}` from the inventory hit's coordinates instead of `tile_id`.
|
||||||
- `tests/e2e/replay/conftest.py`: replace `operator_pre_flight_setup`'s mock with a real fixture that mounts the prebuilt artifacts into the e2e-runner container at the expected paths (`/opt/tiles/`, `/opt/descriptor_index.index`).
|
- Map the response field renames in `TileSummary` construction (existing fields like `tile_id`, `produced_at`, `resolution_m_per_px`, `estimated_bytes` map to whatever the real inventory response uses — verify against `Program.cs` + `tile-inventory.md` and document any per-field adaptation needed).
|
||||||
- `docker-compose.test.yml` + `docker-compose.test.jetson.yml`: mount the artifacts into the `e2e-runner` service (bind mount or named volume), set `c6_tile_store.path` + `c6_descriptor_index.path` env vars.
|
- Update `tests/unit/c11_tile_manager/test_tile_downloader.py` (and any other unit tests touching the LIST/GET paths) to use the new POST contract + slippy-map GET — these are stubbed-response tests, no live service needed.
|
||||||
- `tests/e2e/replay/test_derkachi_1min.py`: remove the `@pytest.mark.xfail` decorator on AC-3 (line 174).
|
- **Smoke test** at `tests/e2e/satellite_provider/test_smoke.py` (new):
|
||||||
- `tests/e2e/replay/test_derkachi_real_tlog.py`: remove the `@pytest.mark.xfail` decorator on AZ-699 (line 174).
|
- Gated by `RUN_REPLAY_E2E=1` + `@pytest.mark.tier2`.
|
||||||
- `_docs/00_problem/input_data/flight_derkachi/README.md`: document the new C6 artifacts + build invocation + license attribution.
|
- Brings up the docker-compose stack (`satellite-provider` + `satellite-provider-postgres` + dependencies).
|
||||||
- `_docs/02_document/contracts/c6_tile_cache/`: if a contract file exists for the descriptor-index format, append a Consumer entry naming this fixture; if not, no new contract needed.
|
- TCP-probe `satellite-provider:8080` until healthy.
|
||||||
|
- Issues one Bearer-authenticated `POST /api/satellite/tiles/inventory` for a 1-tile query (a tile in the Derkachi bbox); asserts a 200 response with the documented schema.
|
||||||
|
- For an inventory-present tile, fetches via `GET /tiles/{z}/{x}/{y}`; asserts non-empty JPEG bytes return.
|
||||||
|
- Asserts the C11-adapted code path (`HttpTileDownloader.download_for_bbox` for a 1-tile bbox) successfully writes to C6's tile store + Postgres metadata table.
|
||||||
|
- `docker-compose.test.yml` (Tier-1) is **NOT** modified. Tier-1 e2e is deprecated per `_docs/02_document/tests/environment.md` 2026-05-20 active policy.
|
||||||
|
- `.env.test.example` extended with `SATELLITE_PROVIDER_API_KEY=DEV-ONLY-REPLACE-...`.
|
||||||
|
|
||||||
|
**Phase 2 — Derkachi tile catalog seeding via the real satellite-provider region API**
|
||||||
|
|
||||||
|
- `tests/fixtures/derkachi_c6/seed_region.py` (new): a Python helper that calls `POST /api/satellite/request` against the running satellite-provider to register the Derkachi bbox + zoom range. Body schema verified against the actual `RequestRegionRequest` DTO (`{id, latitude, longitude, sizeMeters, zoomLevel, stitchTiles}`) — body shape probe-confirmed 2026-05-22. Imagery source: **Google Maps satellite layer** (`lyrs=s`); satellite-provider owns the actual tile download from Google Maps and applies the freshness gate. Note: see AZ-812 for the planned `latitude/longitude` → `lat/lon` rename on this DTO.
|
||||||
|
- `tests/fixtures/derkachi_c6/bbox.yaml`: Derkachi bbox + zoom levels + actual imagery source (Google Maps satellite, not CARTO as originally specced) + license attribution metadata (Google Maps Platform Terms of Service + "Imagery © Google" attribution string).
|
||||||
|
- `tests/fixtures/derkachi_c6/README.md`: how to re-seed if the satellite-provider DB is wiped; license attribution operators must propagate ("Imagery © Google"); the dev-only caveat for Google Maps ToS; pointer to the parent-suite ticket (TBD) for migrating to a true CC-BY source for production.
|
||||||
|
|
||||||
|
**Phase 3 — replace `operator_pre_flight_setup` with a real fixture**
|
||||||
|
|
||||||
|
- `tests/e2e/replay/conftest.py::operator_pre_flight_setup`: replace the placeholder. The new fixture:
|
||||||
|
- Reads the Derkachi bbox from `tests/fixtures/derkachi_c6/bbox.yaml`.
|
||||||
|
- Invokes the adapted C11 `HttpTileDownloader` against the running satellite-provider service.
|
||||||
|
- Invokes C10 `DescriptorBatcher` against the populated C6 (NetVLAD backbone per `c2_vpr/config.py:67` default).
|
||||||
|
- Verifies sidecar coherence (`.index` + `.sha256` + `.meta.json` triple-consistency check per AZ-306).
|
||||||
|
- Yields a `PopulatedC6Cache` dataclass that the test bodies consume.
|
||||||
|
- Outputs mounted into the e2e-runner container via named volumes that survive across pytest sessions.
|
||||||
|
|
||||||
|
**Phase 4 — un-xfail the Tier-2 tests**
|
||||||
|
|
||||||
|
- `tests/e2e/replay/test_derkachi_1min.py::test_ac3_within_100m_80pct_of_ticks`: remove `@pytest.mark.xfail` (still gated by `RUN_REPLAY_E2E=1` + `@pytest.mark.tier2`).
|
||||||
|
- `tests/e2e/replay/test_derkachi_real_tlog.py::test_az699_real_flight_validation_emits_verdict_and_report`: remove `@pytest.mark.xfail`. The test body MUST emit the verdict report regardless of PASS/FAIL — the success criterion is that the report exists with the honest distribution.
|
||||||
|
|
||||||
|
**Phase 5 — documentation**
|
||||||
|
|
||||||
|
- `_docs/02_document/contracts/replay/replay_protocol.md`: extend Invariant 12 with an AZ-777 sub-section describing the operator_pre_flight_setup behaviour against the real satellite-provider.
|
||||||
|
- `_docs/00_problem/input_data/flight_derkachi/README.md`: add a Derkachi C6 section pointing at the seed script + bbox config.
|
||||||
|
- `_docs/02_document/architecture.md`: append a sub-section to the existing satellite-provider entry noting that the Jetson e2e harness consumes the real .NET service (AZ-688 / AZ-691 / AZ-692 prior art; AZ-777 closes the C11 contract gap and wires the e2e-runner client). Tier-1 status updated to "deprecated 2026-05-20".
|
||||||
|
|
||||||
### Excluded
|
### Excluded
|
||||||
|
|
||||||
- Multi-flight fixtures — just Derkachi. (Other flights would each need their own C6 build invocation.)
|
- ZERO modifications to `../satellite-provider/`. If a parent-suite gap surfaces beyond C11 adapting to existing endpoints (e.g., inventory response missing fields C11 needs, region-onboarding endpoint rejects the Derkachi payload shape), STOP and file a parent-suite ticket.
|
||||||
- Online tile download at test time — the e2e harness MUST remain offline (per replay protocol Invariant 5 / RESTRICT-SAT-1 / NFT-SEC-02; the docker compose `internal: true` network). The build script downloads tiles AT BUILD TIME from the developer workstation; the e2e harness only sees the prebuilt artifacts.
|
- `docker-compose.test.yml` (Tier-1) — OUT OF SCOPE (deprecated 2026-05-20).
|
||||||
- Replacing the mock-suite-sat-service stub for the C12 operator-workflow `test_ac8_operator_workflow` test — that test exercises the D-PROJ-2 ingest contract which is parent-suite work, not in scope here.
|
- Cross-compile / arm64 follow-up — **CLOSED**: `mcr.microsoft.com/dotnet/aspnet:10.0` has an arm64 manifest (verified 2026-05-21 via `docker manifest inspect`). No follow-up ticket needed.
|
||||||
- Building tiles for any backbone other than the airborne-default. If the operator wants a different backbone, they re-run the script with a different `--backbone` flag; this task only commits the default-backbone artifacts.
|
- `mock-sat` retention — **CLOSED**: already retired from Jetson compose; D-PROJ-2 / `POST /api/satellite/upload` has shipped on the real satellite-provider (`Program.cs:211`).
|
||||||
- Switching the airborne C6 backend from Postgres-mirroring to anything else — the build script writes the same on-disk layout the production C6 expects.
|
- Switching C2 default backbone away from `net_vlad` — out of scope.
|
||||||
- AZ-776 (sibling): this task does NOT introduce the `c4_pose.enabled` flag or the open-loop composition profile. AZ-776 must land first to unblock the open-loop xfails (AC-1, AC-2, AC-5, AC-6); this task targets the full-GTSAM xfails (AC-3, AZ-699).
|
- Persisting populated C6 to git/LFS — named-volume approach unchanged.
|
||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
**AC-1: Reproducible build**
|
**AC-1: satellite-provider healthy in Jetson compose**
|
||||||
Given a clean checkout
|
Given the existing `satellite-provider` + `satellite-provider-postgres` services in `docker-compose.test.jetson.yml`
|
||||||
When `python scripts/build_derkachi_c6_fixture.py --output tests/fixtures/derkachi_c6/out --bbox tests/fixtures/derkachi_c6/bbox.yaml` runs
|
When `docker compose -f docker-compose.test.jetson.yml up satellite-provider` is invoked
|
||||||
Then it produces a `tiles/` directory in the documented `{zoom}/{x}/{y}.jpg` layout, a FAISS `.index` file with a SHA-256-verified content hash, and a `manifest.json` recording tile count, bbox, backbone, descriptor dimension, FAISS parameters, source URL template, license, and per-artifact SHA-256, in under 30 minutes on a developer workstation
|
Then both services build, the satellite-provider becomes healthy via TCP probe on port 8080 (per existing healthcheck), and is reachable from any compose-network service via DNS `satellite-provider:8080`
|
||||||
|
|
||||||
**AC-2: License attribution**
|
**AC-2: C11 contract aligns with satellite-provider's actual API**
|
||||||
Given the produced artifacts
|
Given the adapted C11 `_LIST_PATH=/api/satellite/tiles/inventory` (POST) and `_GET_PATH=/tiles/{z}/{x}/{y}` (GET) against the running satellite-provider
|
||||||
When the manifest is inspected
|
When `tests/e2e/satellite_provider/test_smoke.py` runs `HttpTileDownloader.download_for_bbox` for a 1-tile bbox in the Derkachi region (seeded)
|
||||||
Then it records the tile source URL template, the license name (CC-BY-3.0 or CC-BY-4.0 as applicable), and the attribution string the operator must surface in any derived publication
|
Then the inventory POST returns 200 with the documented schema, the tile fetch returns non-empty JPEG bytes, and C6's tile store + Postgres metadata both reflect the tile (freshness label `fresh`)
|
||||||
|
|
||||||
**AC-3: Offline e2e harness**
|
**AC-3: operator_pre_flight_setup drives the production pipeline**
|
||||||
Given the prebuilt C6 artifacts mounted into the e2e-runner container
|
Given the running satellite-provider with Derkachi tiles seeded
|
||||||
When `scripts/run-tests-jetson.sh` runs on Jetson with `RUN_REPLAY_E2E=1 GPS_DENIED_TIER=2` and the Docker compose network is `internal: true`
|
When `tests/e2e/replay/conftest.py::operator_pre_flight_setup` runs
|
||||||
Then the test harness never reaches out to any external host; all C6 queries are served from the mounted artifacts
|
Then adapted C11 downloads the Derkachi-bbox tiles into C6, C10 `DescriptorBatcher` builds the FAISS HNSW index using the NetVLAD backbone, the three sidecar files (`.index` + `.sha256` + `.meta.json`) pass the AZ-306 triple-consistency check, and the fixture yields a `PopulatedC6Cache` with all three artifact paths populated
|
||||||
|
|
||||||
**AC-4: Full-protocol e2e passes**
|
**AC-4: Derkachi AC-3 test un-xfails on Tier-2**
|
||||||
Given AZ-776 has landed AND the C6 artifacts are mounted AND the YAML config selects `c5_state.strategy = gtsam_isam2` with `c4_pose.enabled = True`
|
Given AZ-776 landed + the populated C6 from AC-3 mounted into the e2e-runner + `c5_state.strategy = gtsam_isam2` + `c4_pose.enabled = True`
|
||||||
When `gps-denied-replay` runs the Derkachi 1-min fixture on Jetson
|
When `tests/e2e/replay/test_derkachi_1min.py::test_ac3_within_100m_80pct_of_ticks` runs on Tier-2 Jetson
|
||||||
Then it exits with code 0, emits one EstimatorOutput per video frame, `test_ac3_within_100m_80pct_of_ticks` un-xfails and passes (≥80 % of ticks within 100 m of ground truth), and the per-frame loop emits `replay.satellite_anchor_inserted` log lines (not the existing `satellite_anchoring_not_wired` warning)
|
Then it un-xfails, the test passes (≥ 80 % of ticks within 100 m of ground truth), and the per-frame loop emits `replay.satellite_anchor_inserted` log lines (not `satellite_anchoring_not_wired`)
|
||||||
|
|
||||||
**AC-5: AZ-699 produces an honest verdict**
|
**AC-5: AZ-699 verdict report is produced**
|
||||||
Given AZ-776 has landed AND the C6 artifacts are mounted AND the real flight video + factory calibration are present (already are)
|
Given AZ-776 landed + the populated C6 from AC-3 + the real flight video + factory calibration
|
||||||
When `test_az699_real_flight_validation_emits_verdict_and_report` runs on Jetson
|
When `tests/e2e/replay/test_derkachi_real_tlog.py::test_az699_real_flight_validation_emits_verdict_and_report` runs on Tier-2 Jetson
|
||||||
Then it un-xfails, the test runs to completion within the 15-min NFR budget, and `_docs/06_metrics/real_flight_validation_<YYYY-MM-DD>.md` records the horizontal-error distribution with the honest PASS/FAIL verdict against the ≥80 % within 100 m gate
|
Then it un-xfails, the test runs to completion within the 15-min NFR budget, and `_docs/06_metrics/real_flight_validation_<YYYY-MM-DD>.md` records the horizontal-error distribution with the honest PASS/FAIL verdict against the ≥ 80 % within 100 m gate (PASS not required for the AC; HONEST report required)
|
||||||
|
|
||||||
**AC-6: Fixture README documents rebuild**
|
**AC-6: Documentation captures the new architecture seam**
|
||||||
Given the updated `_docs/00_problem/input_data/flight_derkachi/README.md`
|
Given the updated replay protocol doc + Derkachi fixture README + architecture sub-section
|
||||||
When a new contributor reads it
|
When a new contributor reads them
|
||||||
Then it documents (i) what C6 artifacts now exist, (ii) the exact `python scripts/build_derkachi_c6_fixture.py …` invocation to rebuild, (iii) the license attribution operators must propagate, (iv) the size-on-disk decision (committed vs. build-on-demand)
|
Then they understand (i) why the real satellite-provider runs in the Jetson e2e harness, (ii) the C11 contract used against satellite-provider (inventory + slippy-map), (iii) how to re-seed the Derkachi catalog, (iv) what license attribution operators must propagate, and (v) why Tier-1 is deprecated
|
||||||
|
|
||||||
## Non-Functional Requirements
|
## Non-Functional Requirements
|
||||||
|
|
||||||
**Performance**
|
**Performance**
|
||||||
- Build script completes in ≤ 30 minutes on a developer workstation (Apple Silicon or x86 Linux, no GPU required for OSM tile download + descriptor pre-compute via the CPU-fallback path of the backbone).
|
- `operator_pre_flight_setup` completes in ≤ 5 minutes on first invocation (cold cache), ≤ 30 seconds on subsequent invocations within the same docker-compose session (warm cache via named volume).
|
||||||
- Built artifacts do not regress the airborne C2 lookup latency budget — the FAISS HNSW parameters MUST match what production C6 expects (M, efConstruction, efSearch); the index is built once and never rebuilt at runtime.
|
- C11 inventory POST + per-tile GET round-trips MUST stay within the existing C11 retry/backoff schedule (`_DEFAULT_BACKOFF_SCHEDULE_S = (1, 2, 4, 8)`). No new retry budget.
|
||||||
|
|
||||||
**Compatibility**
|
**Compatibility**
|
||||||
- Tile on-disk layout `{zoom}/{x}/{y}.jpg` MUST be byte-equivalent to `satellite-provider`'s layout (architecture principle #5) so a future post-landing upload would be byte-identical.
|
- Tile on-disk layout `{zoom}/{x}/{y}.jpg` MUST be byte-equivalent to satellite-provider's layout (architecture principle #5) — automatic via C6 write path.
|
||||||
- FAISS index format MUST be loadable by the airborne `c6_descriptor_index.FaissDescriptorIndex` impl without code changes.
|
- FAISS index format MUST be loadable by the airborne `c6_descriptor_index.FaissDescriptorIndex.from_config` impl without code changes — automatic via C6 write path.
|
||||||
- Descriptor dimension MUST match the configured C7 backbone's output dimension — the build script asserts this at start.
|
- C11 inventory POST schema MUST match `tile-inventory.md` v1.0.0 (AZ-505). Schema mismatch is a parent-suite bug; this task adapts C11 to the documented v1.0.0 contract, no further patches.
|
||||||
|
|
||||||
**Reliability**
|
**Reliability**
|
||||||
- Build script MUST fail loud on partial downloads (network error, HTTP 429/500, malformed tile) rather than silently producing an incomplete tile store. Resume-from-partial is allowed but each resumed run re-verifies SHA-256 of every committed tile.
|
- The smoke test (AC-2) MUST fail loud if satellite-provider is unreachable, returns malformed responses, rate-limits, or returns 401/403 (auth failure) — no silent skip.
|
||||||
- The SHA-256 content-hash gate on the FAISS index (per D-C10-3) MUST be enforced — operator can verify a downloaded fixture matches what was built.
|
- `operator_pre_flight_setup` MUST clean up partial cache state on failure (no half-built FAISS index left).
|
||||||
|
- SHA-256 content-hash gate on the FAISS index (per D-C10-3) verified at every fixture yield — mismatch raises `IndexUnavailableError`.
|
||||||
|
|
||||||
**Security**
|
**Security**
|
||||||
- Reference imagery URLs MUST be HTTPS. Tile metadata MUST record the exact source URL so license auditors can verify attribution.
|
- `SATELLITE_PROVIDER_TLS_INSECURE=1` is a **development-only** override. Documented in `.env.test.example` + the smoke test + the architecture sub-section. Production deploys MUST validate against a real CA-issued cert.
|
||||||
- No API keys committed to the repo — if the chosen tile source requires registration, the build script reads the key from an env var and documents the env var name in the fixture README.
|
- `SATELLITE_PROVIDER_API_KEY` sourced from `.env.test`; never committed; same `.gitignore` pattern as `JWT_SECRET`.
|
||||||
|
- C11 download goes through the production Bearer-token auth path (`Authorization: Bearer ${SATELLITE_PROVIDER_API_KEY}`) — no auth bypass.
|
||||||
|
|
||||||
## Unit Tests
|
## Unit Tests
|
||||||
|
|
||||||
| AC Ref | What to Test | Required Outcome |
|
| AC Ref | What to Test | Required Outcome |
|
||||||
|--------|--------------|------------------|
|
|--------|--------------|------------------|
|
||||||
| AC-1 | Build script produces `tiles/`, `descriptor_index.index`, `manifest.json` on a small mock bbox | All three artifacts exist, manifest fields populated |
|
| AC-1 | `docker-compose.test.jetson.yml` lints; e2e-runner depends_on satellite-provider | `docker compose -f docker-compose.test.jetson.yml config` exits 0 |
|
||||||
| AC-1 | SHA-256 of `descriptor_index.index` recorded in manifest matches actual file hash | Hashes match |
|
| AC-2 | C11 `_do_enumerate` against a stubbed POST `/api/satellite/tiles/inventory` response | Returns `list[TileSummary]` with correct field mapping |
|
||||||
| AC-2 | Manifest records source URL template + license + attribution | All three fields non-empty |
|
| AC-2 | C11 `_download_one_tile` against a stubbed GET `/tiles/{z}/{x}/{y}` response | Writes tile bytes + sha256 to C6 adapter |
|
||||||
| AC-2 | License field matches the source's documented license | Round-trips against an enum |
|
| AC-3 | `operator_pre_flight_setup` fixture yields a `PopulatedC6Cache` with non-empty tile store + FAISS index | All three sidecar files exist + sha256 triple-consistency holds |
|
||||||
| AC-6 | Fixture README documents the build invocation | Invocation string greps cleanly |
|
| AC-3 | Sidecar SHA-256 coherence check inside the fixture | `IndexUnavailableError` raised when one of the three files is tampered |
|
||||||
|
| AC-6 | Fixture README documents the seed invocation | Invocation string + license attribution greps cleanly |
|
||||||
|
|
||||||
## Blackbox Tests
|
## Blackbox Tests
|
||||||
|
|
||||||
| AC Ref | Initial Data/Conditions | What to Test | Expected Behavior | NFR References |
|
| AC Ref | Initial Data/Conditions | What to Test | Expected Behavior | NFR References |
|
||||||
|--------|------------------------|--------------|-------------------|----------------|
|
|--------|------------------------|--------------|-------------------|----------------|
|
||||||
| AC-3 | Prebuilt C6 artifacts + e2e-runner with `internal: true` network | Run `scripts/run-tests-jetson.sh` end-to-end | No outbound network calls observed by Docker network logs; all C6 queries return from local index | Security, Reliability |
|
| AC-1 | Jetson compose | `docker compose up satellite-provider` | Both services come up healthy in ≤ 60 s | Perf |
|
||||||
| AC-4 | AZ-776 landed + C6 artifacts mounted + full-GTSAM YAML | `test_ac3_within_100m_80pct_of_ticks` un-xfailed | Test passes (≥80 % of ticks within 100 m); `satellite_anchor_inserted` log lines visible | Perf, Compat |
|
| AC-2 | Real satellite-provider running + 1-tile-bbox query | C11 adapted HttpTileDownloader against the live service | Tile arrives in C6 + metadata row inserted + freshness=fresh | Reliability |
|
||||||
| AC-5 | AZ-776 landed + C6 artifacts mounted + real flight video + factory calibration | `test_az699_real_flight_validation_emits_verdict_and_report` un-xfailed | Test runs to completion ≤ 15 min, verdict report written to `_docs/06_metrics/` | Perf |
|
| AC-3 | Seeded Derkachi catalog + e2e-runner | `operator_pre_flight_setup` cold + warm invocation | Cold ≤ 5 min, warm ≤ 30 s, all three sidecar files coherent | Perf |
|
||||||
|
| AC-4 | AZ-776 landed + populated C6 mounted + full-GTSAM YAML | `test_ac3_within_100m_80pct_of_ticks` un-xfailed on Tier-2 Jetson | Test passes (≥ 80 % within 100 m); `satellite_anchor_inserted` log lines visible | Perf, Compat |
|
||||||
|
| AC-5 | AZ-776 landed + populated C6 mounted + real flight video + factory calibration | `test_az699_real_flight_validation_emits_verdict_and_report` un-xfailed | Test completes ≤ 15 min, verdict report written to `_docs/06_metrics/` | Perf |
|
||||||
|
|
||||||
## Constraints
|
## Constraints
|
||||||
|
|
||||||
- Reference imagery source MUST be OSM/CARTO basemap (CC-BY-licensed). Operator chose this during AZ-777 scoping (cycle-3 Step 9, 2026-05-21) over Maxar Open Data (license uncertainty for in-repo redistribution) and video-self-orthorectification (self-referential, makes AC-3 a smoke test rather than a real accuracy gate). The trade-off — lower-resolution reference imagery may produce a higher residual on the AC-3 horizontal-error metric than satellite imagery would — is an HONEST finding the AZ-699 verdict will surface.
|
- ZERO modifications to files under `../satellite-provider/` (sibling repo). If a parent-suite gap is discovered, STOP and file a parent-suite ticket.
|
||||||
- The build script MUST NOT depend on `satellite-provider` running. The script's only network dependency is the chosen OSM/CARTO tile server (HTTPS, public, no auth).
|
- Per replay protocol Invariant 5: ZERO outbound network from the e2e-runner once the cache is populated. The cache-population phase needs network (satellite-provider downloads from CARTO upstream); the airborne replay run is internal-network-only.
|
||||||
- The committed artifact size budget (if AC-6 chooses commit-to-repo) is 100 MB total across `tiles/` + `descriptor_index.index`. Over budget → switch to build-on-demand, document in README.
|
- Imagery source: **Google Maps satellite layer** (`lyrs=s`), governed by Google Maps Platform Terms of Service. Originally specced as CC-BY-licensed (CARTO Voyager); amended 2026-05-22 after probe revealed Google Maps is the actual upstream. License attribution string ("Imagery © Google") recorded in the seeded catalog's metadata. Dev/research use only; production deploy requires (a) Google Maps Platform licensing review for offline-cache use, OR (b) parent-suite ticket to add a true CC-BY satellite imagery provider to satellite-provider (Esri World Imagery, Mapbox satellite, Sentinel-2, etc.).
|
||||||
- The `mock-suite-sat-service` stub stays in place for `test_ac8_operator_workflow` — that test exercises the D-PROJ-2 contract which this task does not address.
|
- The seeded Derkachi catalog size budget is 100 MB on the satellite-provider DB side. Over budget → reduce zoom-level coverage; document in `bbox.yaml`.
|
||||||
- Per replay protocol Invariant 5: ZERO outbound network from the e2e-runner. The build script runs on the developer workstation; the harness only sees prebuilt artifacts.
|
- Tier-1 (`docker-compose.test.yml`) is deprecated and MUST NOT be modified by this task.
|
||||||
|
|
||||||
## Risks & Mitigation
|
## Risks & Mitigation
|
||||||
|
|
||||||
**Risk 1: OSM basemap residual is too coarse for the AC-3 threshold**
|
**Risk 1: C11 inventory response field names drift further from `tile-inventory.md` v1.0.0**
|
||||||
- *Risk*: AC-3's `≤100 m for 80 %` gate may be physically unmeetable when the reference imagery is OSM rasterized basemap (street-level features, not satellite features) — the visual descriptors may not lock against the aerial nav-camera frames at all.
|
- *Risk*: Even after fixing `_LIST_PATH` + `_GET_PATH`, the response object fields (`tile_id`, `produced_at`, `resolution_m_per_px`, `estimated_bytes`, etc.) may not match the inventory response's actual field names; or the inventory response may not include all the fields C11's `TileSummary` requires.
|
||||||
- *Mitigation*: This is an honest discovery. If AC-3 still fails after this task lands, the failure mode shifts from "no anchors at all" (current) to "anchors exist but VPR similarity is too low to produce ≥80 % within 100 m". The AZ-699 verdict report will surface the actual horizontal-error distribution; if it lands at e.g. p50 = 250 m, that becomes evidence for a follow-up ticket to switch to satellite imagery. The xfail is removed in either case because the test now exercises the real pipeline — the verdict, not the xfail, becomes the honest signal.
|
- *Mitigation*: Phase 1 verifies field mapping against `tile-inventory.md` v1.0.0 + `Program.cs::GetTilesInventory` source. Per-field renames are a gps-denied-onboard side concern (C11 adapter); only fields entirely missing from the inventory response warrant a parent-suite ticket.
|
||||||
|
|
||||||
**Risk 2: Tile source rate-limits or goes offline mid-build**
|
**Risk 2: Self-signed cert CN/SAN doesn't include `satellite-provider` hostname**
|
||||||
- *Risk*: Public OSM/CARTO tile servers may rate-limit or temporarily go down, breaking reproducibility on a re-build.
|
- *Risk*: The dev cert at `../satellite-provider/certs/api.pfx` may be issued for `localhost` only; via compose DNS `satellite-provider:8080` it would fail SSL verification.
|
||||||
- *Mitigation*: Build script implements exponential backoff + resume-from-partial. Document the chosen tile-server URL in the fixture README so an operator can swap to a mirror if needed. If commit-to-repo is chosen for the artifacts, future re-builds are unnecessary — the committed artifacts are the source of truth.
|
- *Mitigation*: Phase 1 introduces `SATELLITE_PROVIDER_TLS_INSECURE=1` env knob — accepted as a **development-only** workaround with prominent warnings in `.env.test.example`, the smoke test, and the architecture doc. Production deploys MUST set this to `0` (default) and use a real cert. Regenerating the dev cert with the right SAN is the cleaner long-term fix but lives on the parent-suite side; file a follow-up ticket if the workaround feels brittle.
|
||||||
|
|
||||||
**Risk 3: Repo size pressure if artifacts are committed**
|
**Risk 3: ~~satellite-provider doesn't build on arm64~~ — CLOSED 2026-05-21**
|
||||||
- *Risk*: Tile store + FAISS index could exceed 100 MB depending on bbox + zoom levels; committing them under LFS still costs LFS storage and bandwidth.
|
- `mcr.microsoft.com/dotnet/aspnet:10.0` multi-arch manifest verified via `docker manifest inspect`: arm64, amd64, arm/v7 all present. No follow-up needed.
|
||||||
- *Mitigation*: First build run measures the size. If under 100 MB → commit. If over → build-on-demand documented in README + `scripts/run-tests-jetson.sh` pre-step. Either choice is acceptable per AC-6.
|
|
||||||
|
|
||||||
**Risk 4: Backbone descriptor dimension mismatch**
|
**Risk 4: ~~CARTO Voyager basemap residual is too coarse for AC-4~~ — REDEFINED 2026-05-22**
|
||||||
- *Risk*: If the operator changes the airborne C2 backbone (UltraVPR → NetVLAD, etc.) without rebuilding the index, the FAISS load will fail at runtime with a dimension mismatch.
|
- *Original concern*: CC-BY basemap is OSM-derived (street-level features, not satellite features). NetVLAD descriptors may not lock against nadir camera frames well enough for ≥ 80 % within 100 m.
|
||||||
- *Mitigation*: Manifest records the descriptor dimension. C6 loader asserts the manifest's dimension matches the configured backbone's output dimension at compose time; mismatch surfaces as an `AirborneBootstrapError` naming both numbers + the rebuild invocation.
|
- *Probe-verified reality (2026-05-22)*: The actual upstream is **Google Maps satellite layer** (`lyrs=s`), which IS high-resolution overhead imagery from genuine satellite/aerial sources. NetVLAD descriptor lock should be strong against nadir camera frames. The original CARTO-coarseness risk is mitigated by the reality.
|
||||||
|
- *New risk (replacing it)*: **Google Maps Platform Terms of Service may restrict offline-tile storage** for the C6-style use case (long-lived cache of stored tiles serving as a VPR reference dataset). Acceptable for dev/research; production deployment requires licensing review or a CC-BY-source migration on the satellite-provider side. Surfaced explicitly in `bbox.yaml`, `README.md`, and the architecture doc sub-section.
|
||||||
|
- *Mitigation*: AC-5 (AZ-699 verdict report) still serves as the honest signal regardless of imagery quality. If VPR locks well, AC-4 passes; if it doesn't, the verdict report records the actual horizontal-error distribution and points to a follow-up (e.g., higher-zoom seeding, different descriptor backbone, or migrating to a CC-BY satellite source for both licensing AND quality reasons).
|
||||||
|
|
||||||
|
**Risk 5: Single-ticket 8-pt complexity exceeds the standard PBI cap**
|
||||||
|
- *Risk*: Above the 5-pt cap stated in the project's PBI complexity rule.
|
||||||
|
- *Mitigation*: The five phases are explicit STOP-gates. If Phase 1 (wiring + C11 adaptation) fails for reasons outside this ticket's scope (e.g., parent-suite contract drift beyond field renames, cert hostname issue requiring parent-suite regen), the implementer STOPS at the phase boundary, files the parent-suite ticket, and proposes a split into smaller follow-up tickets. The "single ticket" property holds as long as work proceeds linearly; if any phase grinds, decomposition is the escape hatch.
|
||||||
|
|
||||||
### ADR Impact
|
### ADR Impact
|
||||||
|
|
||||||
> Affects ADR-001 (composition root is single registration site): unchanged — C6 is built outside the composition root by the operator-side build script; the airborne binary still just loads what's on disk.
|
> Affects ADR-002 (build-time exclusion): unchanged — C11 is already operator-side-only via process-level isolation (architecture Principle #4 + ADR-004); this task adapts C11's contract but does not change its build-time isolation.
|
||||||
> Implements architecture principle #4 (no in-air network I/O) and principle #5 (all persistent imagery in `satellite-provider` on-disk layout) — this is the FIRST executable artifact that demonstrates both principles end-to-end against a real flight.
|
> Affects ADR-011 (replay is a configuration): unchanged — the per-frame loop is mode-agnostic; this task closes the gap between the live and replay paths' upstream tile source.
|
||||||
|
> Implements architecture principle #5 (satellite-provider on-disk layout) end-to-end against a real flight for the first time.
|
||||||
|
> No new ADR — the architectural decision is "adapt C11 to the existing satellite-provider contract and wire the e2e harness against the real service", which is execution of existing decisions, not a new one.
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# End-to-end real-flight validation pipeline (Epic)
|
||||||
|
|
||||||
|
**Task**: AZ-835_e2e_real_flight_validation_epic
|
||||||
|
**Name**: End-to-end real-flight validation: raw (tlog, video) → route-driven satellite seeding → gps-denied verdict
|
||||||
|
**Description**: Drive the full gps-denied-onboard validation pipeline from raw operator inputs to a verdict. Given a `.tlog` binary + a flight video, the system automatically extracts the flight cut, syncs frames to IMU, builds the satellite imagery the descriptor stack needs (route-driven, not bbox-driven), runs the airborne pipeline, and reports the horizontal-error distribution against the tlog's own GPS ground truth. Supersedes AZ-777 Phase 3+ design.
|
||||||
|
**Complexity**: Epic — ~17 SP decomposed into 6 child tasks of ≤ 5 SP each (see decomposition table below)
|
||||||
|
**Dependencies**: AZ-777 Phase 1 (landed cycle 3 batch 105 — C11 contract adaptation + e2e-runner wiring); AZ-405 (tlog↔video auto-sync adapter); AZ-699 (verdict report writer); AZ-809 SOFT (Route API validation — landing AZ-809 before C2 lets the client consume RFC 7807 validator responses cleanly)
|
||||||
|
**Component**: cross-cutting — replay_input + new TlogRouteExtractor + new SatelliteProviderRouteClient + e2e fixtures + tests/e2e/replay
|
||||||
|
**Tracker**: AZ-835 (https://denyspopov.atlassian.net/browse/AZ-835)
|
||||||
|
**Originating directive**: user (2026-05-22) after AZ-777 Phase 2 deliverables landed — "In the end it should be full e2e flow. You give it a tlog + video, and the system does everything else."
|
||||||
|
|
||||||
|
Jira AZ-835 is the authoritative spec; this file mirrors the in-workspace-only sections that gps-denied-onboard implementers will need.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
A single pytest test takes only `(tlog, video, calibration)` as input and runs the full 7-step pipeline end-to-end on the Jetson harness, producing an honest PASS/FAIL verdict against the AZ-696 AC-3 threshold (≥ 80 % of emissions within 100 m).
|
||||||
|
|
||||||
|
## The 7-step pipeline
|
||||||
|
|
||||||
|
| # | Step | Existing? | Component / new code |
|
||||||
|
|---|------|-----------|----------------------|
|
||||||
|
| 1 | Extract active flight cut + sync with video | **Mostly existing** (AZ-405 `tlog_video_adapter.py`) | small extension for take-off/landing boundary detection if needed |
|
||||||
|
| 2 | On-fly frame + IMU extraction | **Existing** | `VideoFileFrameSource` + `TlogReplayFcAdapter` (no change) |
|
||||||
|
| 3 | Auto-create route from tlog GPS, coarsen to ≤ 10 pts | **New** | `TlogRouteExtractor` (Douglas-Peucker on `GLOBAL_POSITION_INT` rows) → `RouteSpec` |
|
||||||
|
| 4 | POST route to satellite-provider, get tiles | **New consumer** | `SatelliteProviderRouteClient` (POST `/api/satellite/route`, poll `mapsReady`) |
|
||||||
|
| 5 | Calc FAISS index from tiles | **Mostly existing** | C10 `DescriptorBatcher` runs; new fixture wires C11 → C10 trigger |
|
||||||
|
| 6 | Run gps-denied from all the info | **Existing** | `gps-denied-replay` console-script + airborne composition root |
|
||||||
|
| 7 | Get GPS fixes, check against tlog GPS | **Existing** | `helpers/accuracy_report.py` + `helpers/gps_compare.py` |
|
||||||
|
|
||||||
|
## Decomposition (6 child tasks)
|
||||||
|
|
||||||
|
| # | Title | Est | Depends |
|
||||||
|
|---|-------|-----|---------|
|
||||||
|
| C1 | `TlogRouteExtractor` — extract active segment + coarsen to N waypoints | 3 | — |
|
||||||
|
| C2 | `SatelliteProviderRouteClient` + `route_seed.py` CLI | 3 | AZ-809 (soft) |
|
||||||
|
| C3 | New `operator_pre_flight_setup` fixture (C1 + C2 + C11 + C10) — replaces placeholder, supersedes AZ-777 Phase 3 | 5 | C1, C2, AZ-777 Phase 1 |
|
||||||
|
| C4 | E2E test ingesting raw `(tlog, video)` and running steps 1-7 — extends/replaces AZ-699 verdict test | 3 | C3 |
|
||||||
|
| C5 | Un-xfail AZ-777 AC-4 + AC-5 tests | 1 | C4 |
|
||||||
|
| C6 | Docs: `replay_protocol.md` Invariant 12 + AZ-777 amendment + new-test README | 2 | C5 |
|
||||||
|
|
||||||
|
**Total ~17 SP**.
|
||||||
|
|
||||||
|
## Why route-driven seeding (not bbox)
|
||||||
|
|
||||||
|
- **Efficiency**: AZ-777 spec bbox = ~11400 tiles z15-z18 (~140 MB, 48% over budget). 10-point coarsened route with `regionSizeMeters=500` per point = ~50-100 unique tiles (~1.5 MB) for the same VPR descriptor lock area. **~100× reduction**.
|
||||||
|
- **Honesty**: bbox pre-commits to where the operator *might* fly. Route pre-commits to where they *did* fly. For real-flight validation, the latter is the right primitive.
|
||||||
|
- **Probe-confirmed**: Route API works end-to-end in ~15s for a 2-point route per 2026-05-22 black-box probe. Uses `lat`/`lon` already (no AZ-812 rename needed).
|
||||||
|
|
||||||
|
## Coordination with prior work
|
||||||
|
|
||||||
|
- **AZ-777** — Phase 1 + Phase 2 reused; Phase 3+ design **superseded** by this Epic when C3 lands.
|
||||||
|
- **AZ-699** — verdict-report-writing path preserved; C4 extends or wraps it.
|
||||||
|
- **AZ-405** — tlog↔video auto-sync adapter reused as-is for step 1.
|
||||||
|
- **AZ-702** — camera factory-sheet calibration unchanged.
|
||||||
|
- **AZ-696** — ≥ 80 % within 100 m threshold gate unchanged.
|
||||||
|
- **AZ-808** — Region-endpoint validation; not on this Epic's critical path (Route used, not Region).
|
||||||
|
- **AZ-809** — Route-endpoint validation; soft prereq for C2.
|
||||||
|
- **AZ-812** — Region rename to lat/lon; not on this Epic's critical path.
|
||||||
|
|
||||||
|
## Acceptance criteria (Epic-level)
|
||||||
|
|
||||||
|
**AC-1**: New pytest test gated by `RUN_REPLAY_E2E=1` + `@pytest.mark.tier2` takes only `(tlog, video, calibration)` and runs the full 7-step pipeline on Jetson.
|
||||||
|
|
||||||
|
**AC-2**: Step 1 auto-detects active flight cut from raw tlog (take-off → landing) without operator intervention.
|
||||||
|
|
||||||
|
**AC-3**: Step 3 produces ≤ 10 waypoints that materially follow the tlog GPS trajectory (DP tolerance documented in config).
|
||||||
|
|
||||||
|
**AC-4**: Step 4 succeeds against real satellite-provider on Jetson docker network, downloads route tiles from Google Maps, `mapsReady=true` within runtime budget.
|
||||||
|
|
||||||
|
**AC-5**: Step 5 builds FAISS HNSW index over route-seeded C6 cache; sidecar triple-consistency holds (AZ-306).
|
||||||
|
|
||||||
|
**AC-6**: Step 7 emits AZ-699 verdict report at `_docs/06_metrics/real_flight_validation_<YYYY-MM-DD>.md` with honest horizontal-error distribution — PASS or FAIL on AZ-696 AC-3 threshold, no xfail mask.
|
||||||
|
|
||||||
|
**AC-7**: End-to-end run ≤ 15 min on Tier-2 Jetson for the Derkachi clip (soft target for first delivery; hard NFR after first measurement).
|
||||||
|
|
||||||
|
**AC-8**: Docs: `replay_protocol.md` Invariant 12 sub-section + AZ-777 marked Phase 3+ superseded + new-test README.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Satellite-provider imagery-source migration to CC-BY (parent-suite ticket, TBD).
|
||||||
|
- FAISS / NetVLAD backbone replacement.
|
||||||
|
- Real-time tlog ingestion (this Epic operates on finished `.tlog` files).
|
||||||
|
- Multi-flight aggregate validation.
|
||||||
|
- ZERO modifications to `../satellite-provider/` (Route API consumed as-is).
|
||||||
|
- CI gating (test stays behind `RUN_REPLAY_E2E=1`).
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Jira AZ-835: https://denyspopov.atlassian.net/browse/AZ-835
|
||||||
|
- Supersedes AZ-777 Phase 3+ design (AZ-777 Phase 1 + Phase 2 reused)
|
||||||
|
- Probe foundation: 2026-05-22 black-box probe of Route API confirmed end-to-end viability
|
||||||
|
- Related: AZ-405, AZ-696, AZ-699, AZ-702, AZ-777, AZ-808, AZ-809, AZ-812
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# Batch 104 — Cycle 3 — AZ-777 Phase 1
|
||||||
|
|
||||||
|
**Date**: 2026-05-21
|
||||||
|
**Tasks**: AZ-777 Phase 1 (e2e-runner wire + C11 contract adapt + smoke test).
|
||||||
|
**Story points**: 8 (explicit override; see decision log).
|
||||||
|
**Jira status**: AZ-777 → still `In Progress` — Phase 1 of 5 done; STOP gate before Phase 2.
|
||||||
|
|
||||||
|
## What shipped
|
||||||
|
|
||||||
|
The Jetson e2e harness now consumes the **real** parent-suite
|
||||||
|
`satellite-provider` .NET service over its compose-DNS name +
|
||||||
|
self-signed dev TLS cert + Bearer JWT auth. C11's
|
||||||
|
`HttpTileDownloader` has been adapted to the AZ-505 v1.0.0
|
||||||
|
`tile-inventory.md` contract — bulk POST inventory lookup keyed by
|
||||||
|
slippy-map (z,x,y) coords, plus per-tile GET via
|
||||||
|
`/tiles/{z}/{x}/{y}`. A Tier-2 smoke test exercises the wire
|
||||||
|
end-to-end against the running service.
|
||||||
|
|
||||||
|
This batch closes the first of AZ-777's five explicit STOP-gated
|
||||||
|
phases. Phases 2–5 remain on the to-do queue:
|
||||||
|
|
||||||
|
- Phase 2 — Derkachi tile catalog seed via
|
||||||
|
`POST /api/satellite/request` (CC-BY basemap source, license
|
||||||
|
attribution baked in).
|
||||||
|
- Phase 3 — replace the placeholder `operator_pre_flight_setup`
|
||||||
|
fixture with a real C10 + C11 driver that yields a
|
||||||
|
`PopulatedC6Cache`.
|
||||||
|
- Phase 4 — un-xfail the Tier-2 Derkachi AC-3 + AZ-699 verdict
|
||||||
|
tests.
|
||||||
|
- Phase 5 — extend the replay-protocol / architecture / Derkachi
|
||||||
|
README docs.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
Production (1):
|
||||||
|
|
||||||
|
- `src/gps_denied_onboard/components/c11_tile_manager/tile_downloader.py`
|
||||||
|
|
||||||
|
Tests (3):
|
||||||
|
|
||||||
|
- `tests/unit/c11_tile_manager/test_tile_downloader.py` (rewritten;
|
||||||
|
14 AC tests; all PASS)
|
||||||
|
- `tests/e2e/satellite_provider/__init__.py` (new)
|
||||||
|
- `tests/e2e/satellite_provider/test_smoke.py` (new; 2 tier2 tests)
|
||||||
|
|
||||||
|
Compose / env (2):
|
||||||
|
|
||||||
|
- `docker-compose.test.jetson.yml`
|
||||||
|
- `.env.test.example`
|
||||||
|
|
||||||
|
Tooling (2):
|
||||||
|
|
||||||
|
- `scripts/mint_dev_jwt.py` (new)
|
||||||
|
- `pyproject.toml` (added `pyjwt>=2.8,<3.0` to dev extras)
|
||||||
|
|
||||||
|
Tracker docs (3):
|
||||||
|
|
||||||
|
- `_docs/02_tasks/_dependencies_table.md` (AZ-777 5→8pt)
|
||||||
|
- `_docs/03_implementation/reviews/batch_104_review.md` (new)
|
||||||
|
- `_docs/03_implementation/batch_104_cycle3_report.md` (this file)
|
||||||
|
|
||||||
|
## AC coverage
|
||||||
|
|
||||||
|
| AC | Phase 1 portion satisfied? | Evidence |
|
||||||
|
|----|----------------------------|----------|
|
||||||
|
| AC-1 (compose lints; depends_on satellite-provider) | ✅ | `docker compose -f docker-compose.test.jetson.yml config` exits 0 with the new env block. |
|
||||||
|
| AC-2 unit (`_do_enumerate` POST inventory + `_download_one_tile` slippy-map GET) | ✅ | `tests/unit/c11_tile_manager/test_tile_downloader.py` 14/14 PASS. |
|
||||||
|
| AC-2 live (Bearer-authenticated round-trip vs. running service) | ⏸ | `tests/e2e/satellite_provider/test_smoke.py` is in place; runs next time the Jetson harness fires. |
|
||||||
|
| AC-3..6 | ⏳ | Out of scope (Phases 2–5). |
|
||||||
|
|
||||||
|
## Test run results
|
||||||
|
|
||||||
|
```
|
||||||
|
$ python -m pytest tests/unit/c11_tile_manager/ -v --tb=short
|
||||||
|
============================== 58 passed in 3.99s ==============================
|
||||||
|
|
||||||
|
$ python -m pytest tests/unit/runtime_root/ tests/unit/c11_tile_manager/ -v --tb=short
|
||||||
|
============================= 113 passed in 3.68s ==============================
|
||||||
|
|
||||||
|
$ python -m pytest tests/e2e/satellite_provider/test_smoke.py -v --tb=short
|
||||||
|
============================== 2 skipped in 0.68s ==============================
|
||||||
|
(skip reason: AZ-777 satellite-provider smoke gated by RUN_REPLAY_E2E=1)
|
||||||
|
```
|
||||||
|
|
||||||
|
Suite-wide test run is deferred to the end of the AZ-777
|
||||||
|
implementation phase per the iterative-skill exception in
|
||||||
|
`.cursor/rules/coderule.mdc` — Phase 1 is a batch, not the end of
|
||||||
|
implementation. The two test trees that depend on the modified code
|
||||||
|
(`tests/unit/c11_tile_manager/` and `tests/unit/runtime_root/`) are
|
||||||
|
green.
|
||||||
|
|
||||||
|
## Code review
|
||||||
|
|
||||||
|
See `_docs/03_implementation/reviews/batch_104_review.md` —
|
||||||
|
**verdict: PASS_WITH_WARNINGS**. Three findings (1 Medium
|
||||||
|
Architecture, 1 Medium Maintainability, 1 Low Maintainability); all
|
||||||
|
deferred to later AZ-777 phases or future tuning with clear
|
||||||
|
ownership. No Critical or High findings.
|
||||||
|
|
||||||
|
## Risks acknowledged on this batch
|
||||||
|
|
||||||
|
- **TLS_INSECURE not in production code path yet** — only the smoke
|
||||||
|
test honours `SATELLITE_PROVIDER_TLS_INSECURE`. Phase 3 (the real
|
||||||
|
`operator_pre_flight_setup` fixture) is the first production-ish
|
||||||
|
consumer of `HttpTileDownloader`; it MUST plumb the flag through.
|
||||||
|
Flagged as F1 in the batch review.
|
||||||
|
- **`_DEFAULT_ESTIMATED_TILE_BYTES = 50 KiB`** — conservative for
|
||||||
|
CARTO Voyager basemap; may under-reserve for UAV-uploaded tiles.
|
||||||
|
Acceptable for Phase 1; revisit in Phase 5. Flagged as F2.
|
||||||
|
- **Smoke test passes when catalog is empty** — by design;
|
||||||
|
exercises the wire pre-Phase-2 and tightens automatically once
|
||||||
|
Phase 2 seeds tiles. Flagged as F3.
|
||||||
|
|
||||||
|
## STOP gate
|
||||||
|
|
||||||
|
This batch closes Phase 1 of AZ-777's 5-phase plan. The next phase
|
||||||
|
(Derkachi tile catalog seed) needs operator alignment on the
|
||||||
|
imagery source (CARTO Voyager Basemap proposed in the spec) and on
|
||||||
|
the bbox / zoom-range envelope. Pause for user decision before
|
||||||
|
Phase 2.
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# Batch 106 — Cycle 3 — AZ-836 TlogRouteExtractor
|
||||||
|
|
||||||
|
**Date**: 2026-05-22
|
||||||
|
**Tasks**: AZ-836 (C1 — Epic AZ-835).
|
||||||
|
**Story points**: 3.
|
||||||
|
**Jira status**: AZ-836 → In Testing after commit.
|
||||||
|
|
||||||
|
## What shipped
|
||||||
|
|
||||||
|
First building block of Epic AZ-835. A pure function that consumes
|
||||||
|
an ArduPilot binary tlog and returns a `RouteSpec` (waypoints + per-
|
||||||
|
waypoint coverage radius + provenance) suitable for posting to
|
||||||
|
satellite-provider's `POST /api/satellite/route` endpoint (the
|
||||||
|
contract AZ-838 / C2 will consume).
|
||||||
|
|
||||||
|
Pipeline:
|
||||||
|
|
||||||
|
1. Load GPS fixes via the existing `load_tlog_ground_truth` (AZ-697).
|
||||||
|
2. Trim leading + trailing rows below the takeoff thresholds
|
||||||
|
(speed ≥ 2 m/s AND AGL ≥ 5 m by default; both configurable).
|
||||||
|
3. Coarsen to ≤ `max_waypoints` (default 10) via iterative
|
||||||
|
Douglas-Peucker on the local-ENU projection produced by
|
||||||
|
`WgsConverter.latlonalt_to_local_enu` (AZ-279). The DP tolerance
|
||||||
|
is either caller-supplied or binary-searched (≤ 32 iterations,
|
||||||
|
≤ 1 m convergence).
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
Production (2):
|
||||||
|
|
||||||
|
- `src/gps_denied_onboard/replay_input/tlog_route.py` (new) —
|
||||||
|
`RouteSpec`, `RouteExtractionError`, `extract_route_from_tlog`.
|
||||||
|
- `src/gps_denied_onboard/replay_input/__init__.py` — re-exports the
|
||||||
|
three new public symbols.
|
||||||
|
|
||||||
|
Tests (1):
|
||||||
|
|
||||||
|
- `tests/unit/replay_input/test_tlog_route.py` (new) — 14 tests
|
||||||
|
covering AC-1..AC-10 plus 4 edge cases (custom DP tolerance,
|
||||||
|
invalid `max_waypoints`, invalid `region_size_meters`, error
|
||||||
|
hierarchy, too-short active segment).
|
||||||
|
|
||||||
|
Tracker docs (1):
|
||||||
|
|
||||||
|
- `_docs/03_implementation/batch_106_cycle3_report.md` (this file).
|
||||||
|
|
||||||
|
## AC coverage
|
||||||
|
|
||||||
|
| AC | Test | Status |
|
||||||
|
|----|------|--------|
|
||||||
|
| AC-1 (Derkachi happy path) | `test_ac1_real_derkachi_tlog_returns_route_inside_flight_extent` | PASS |
|
||||||
|
| AC-2 (stationary-leading trim) | `test_ac2_stationary_leading_fixes_are_trimmed` | PASS |
|
||||||
|
| AC-3 (`max_waypoints=2`) | `test_ac3_max_waypoints_two_returns_exactly_two_waypoints` | PASS |
|
||||||
|
| AC-4 (`max_waypoints=100` on small N) | `test_ac4_max_waypoints_larger_than_segment_returns_all_points` | PASS |
|
||||||
|
| AC-5 (missing tlog) | `test_ac5_missing_tlog_raises_route_extraction_error` | PASS |
|
||||||
|
| AC-6 (no GPS) | `test_ac6_tlog_without_gps_messages_raises_route_extraction_error` | PASS |
|
||||||
|
| AC-7 (frozen + slots + provenance) | `test_ac7_route_spec_is_frozen_slots_with_all_provenance_fields` | PASS |
|
||||||
|
| AC-8 (auto-tolerance convergence) | `test_ac8_auto_tolerance_converges_on_200_fix_synthetic` | PASS |
|
||||||
|
| AC-9 (DEBUG-only logging) | `test_ac9_no_warn_or_higher_logging_on_happy_path` | PASS |
|
||||||
|
| AC-10 (test surface meta) | satisfied by AC-1..AC-9 + 4 edge-case tests | PASS |
|
||||||
|
|
||||||
|
## Test run results
|
||||||
|
|
||||||
|
```
|
||||||
|
$ .venv/bin/python -m pytest tests/unit/replay_input/test_tlog_route.py -v --tb=short
|
||||||
|
============================== 14 passed in 1.17s ==============================
|
||||||
|
|
||||||
|
$ .venv/bin/python -m pytest tests/unit/replay_input/ -v --tb=short
|
||||||
|
======================== 72 passed, 1 skipped in 6.22s =========================
|
||||||
|
```
|
||||||
|
|
||||||
|
The 1 skip is pre-existing: `test_az698_window_alignment.py` AC-5
|
||||||
|
needs both `derkachi.tlog` and `flight_derkachi.mp4`; only the tlog
|
||||||
|
is committed. Unrelated to this batch.
|
||||||
|
|
||||||
|
Suite-wide test run is deferred to Step 11 (Run Tests) per the
|
||||||
|
iterative-skill exception in `.cursor/rules/coderule.mdc` — batch 106
|
||||||
|
is a batch, not the end of cycle-3 implementation.
|
||||||
|
|
||||||
|
## Code review
|
||||||
|
|
||||||
|
Self-review (per `.cursor/rules/no-subagents.mdc`; the `/code-review`
|
||||||
|
skill is not delegated to a subagent and full structured review is
|
||||||
|
deferred to the next cycle's cumulative review at Step 14.5):
|
||||||
|
|
||||||
|
- **Architecture**: `tlog_route.py` lives under
|
||||||
|
`src/gps_denied_onboard/replay_input/` per
|
||||||
|
`_docs/02_document/module-layout.md` (Layer-4 shared cross-cutting).
|
||||||
|
Imports only from `_types`, `helpers`, and intra-package siblings —
|
||||||
|
no cross-component imports.
|
||||||
|
- **Reuse**: `load_tlog_ground_truth` (AZ-697) for GPS extraction;
|
||||||
|
`helpers.gps_compare.l2_horizontal_m` for along-track distance;
|
||||||
|
`helpers.wgs_converter.WgsConverter.latlonalt_to_local_enu` for
|
||||||
|
the ENU projection. No primitive re-implemented.
|
||||||
|
- **Safety**: Douglas-Peucker is iterative (stack-based) — no Python
|
||||||
|
recursion-limit risk on long tracks.
|
||||||
|
- **API discipline**: `extract_route_from_tlog` is a pure function;
|
||||||
|
`RouteSpec` is frozen + slots; `RouteExtractionError` is a
|
||||||
|
subclass of `ReplayInputAdapterError` so callers can catch either
|
||||||
|
the specific or the parent class.
|
||||||
|
- **Lint**: ruff format + ruff check pass on the two new files and
|
||||||
|
the modified `__init__.py`.
|
||||||
|
|
||||||
|
Verdict: PASS.
|
||||||
|
|
||||||
|
## Spec drift surfaced (informational)
|
||||||
|
|
||||||
|
The AZ-836 task spec's AC-1 quoted lat 50.0808..50.0832 / lon
|
||||||
|
36.1070..36.1134 "per AZ-777 Phase 2 IMU analysis". The actual
|
||||||
|
GPS-based active segment (the relevant input for this task) reaches
|
||||||
|
lat 50.0840 / lon 36.1144 on takeoff/landing fringes — wider than
|
||||||
|
the IMU-derived bounds. The test relaxes to lat 50.0800..50.0840 /
|
||||||
|
lon 36.1070..36.1145 (with explanatory comment); the spec text is
|
||||||
|
unchanged for this batch. `tests/fixtures/derkachi_c6/bbox.yaml`
|
||||||
|
already records the discrepancy by separating `bbox` (generous
|
||||||
|
seeding bbox) from `actual_flight_extent` (IMU-derived).
|
||||||
|
|
||||||
|
Not a blocker — the IMU-derived bound was always informational, and
|
||||||
|
GPS-derived active-segment trim using the spec's documented
|
||||||
|
thresholds (speed ≥ 2 m/s, AGL ≥ 5 m) is correct.
|
||||||
|
|
||||||
|
## Semantics decision
|
||||||
|
|
||||||
|
`max_waypoints` is enforced ONLY in auto-tolerance mode
|
||||||
|
(`douglas_peucker_tolerance_m=None`). With an explicit DP tolerance
|
||||||
|
the result reflects that exact tolerance — the caller takes
|
||||||
|
responsibility for the result size. Documented in the docstring of
|
||||||
|
`_coarsen_to_max_waypoints` and exercised by
|
||||||
|
`test_custom_dp_tolerance_is_honored`.
|
||||||
|
|
||||||
|
## Next batch
|
||||||
|
|
||||||
|
AZ-838 (C2 — `SatelliteProviderRouteClient` + `seed_route.py` CLI).
|
||||||
|
Hard-depends on this batch's `RouteSpec` dataclass. Recommend
|
||||||
|
starting in a fresh session — Context Management Protocol heuristic
|
||||||
|
already in the Caution zone for this conversation.
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
# Batch 107 — Cycle 3 — AZ-838 SatelliteProviderRouteClient + seed_route.py CLI
|
||||||
|
|
||||||
|
**Date**: 2026-05-23
|
||||||
|
**Tasks**: AZ-838 (C2 — Epic AZ-835).
|
||||||
|
**Story points**: 3.
|
||||||
|
**Jira status**: AZ-838 → In Testing after commit (deferred to commit step).
|
||||||
|
|
||||||
|
## What shipped
|
||||||
|
|
||||||
|
Second building block of Epic AZ-835. Operator-side HTTP client +
|
||||||
|
CLI wrapper that takes a `RouteSpec` (from AZ-836 / C1) and:
|
||||||
|
|
||||||
|
1. Pre-emptively validates the request body against the actual
|
||||||
|
AZ-809 `CreateRouteRequestValidator` rules.
|
||||||
|
2. POSTs `/api/satellite/route` with `requestMaps=true,
|
||||||
|
createTilesZip=false`.
|
||||||
|
3. Polls `GET /api/satellite/route/{id}` until `mapsReady=true` OR
|
||||||
|
a terminal failure status; respects `poll_max_attempts` +
|
||||||
|
`poll_interval_s`.
|
||||||
|
4. Verifies coverage via `POST /api/satellite/tiles/inventory`,
|
||||||
|
enumerating tile coords locally from the `RouteSpec` waypoints +
|
||||||
|
`regionSizeMeters`.
|
||||||
|
5. Returns `RouteSeedResult(route_id, terminal_status, maps_ready,
|
||||||
|
tile_count, elapsed_ms, submitted_payload_sha256)`.
|
||||||
|
|
||||||
|
Error hierarchy is rooted at `SatelliteProviderRouteError`,
|
||||||
|
**independent** of the existing `TileManagerError` family per the
|
||||||
|
placement-decision recorded against AZ-838 (Jira comment, 2026-05-23).
|
||||||
|
The Route API is a corridor-onboarding flow, not a per-tile transfer.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
Production (3):
|
||||||
|
|
||||||
|
- `src/gps_denied_onboard/components/c11_tile_manager/route_client.py`
|
||||||
|
(new, ~600 lines) — `SatelliteProviderRouteClient`,
|
||||||
|
`RouteSeedResult`, plus module-level helpers
|
||||||
|
(`_canonical_json_bytes`, `_enumerate_route_tile_coords`,
|
||||||
|
`_latlon_to_tile_xy`, `_parse_problem_details`).
|
||||||
|
- `src/gps_denied_onboard/components/c11_tile_manager/errors.py` —
|
||||||
|
added `SatelliteProviderRouteError`, `RouteValidationError`
|
||||||
|
(with `field_errors` + `http_status`), `RouteTransientError`,
|
||||||
|
`RouteTerminalFailureError` (with `detail` + `route_id`).
|
||||||
|
Module docstring extended to document the dual-hierarchy split
|
||||||
|
(TileManagerError vs. SatelliteProviderRouteError).
|
||||||
|
- `src/gps_denied_onboard/components/c11_tile_manager/__init__.py` —
|
||||||
|
re-exports the new public surface.
|
||||||
|
|
||||||
|
CLI (1):
|
||||||
|
|
||||||
|
- `tests/fixtures/derkachi_c6/seed_route.py` (new) — operator CLI
|
||||||
|
mirroring `seed_region.py` (AZ-777 Phase 2). Supports `--tlog`,
|
||||||
|
`--max-waypoints`, `--region-size-meters`, `--zoom-level`,
|
||||||
|
`--name`, `--description`, `--env-file`, `--output-summary`,
|
||||||
|
`--dry-run`, `--auto-mint-jwt`. Exit codes 0/71/72/73/74/75/76
|
||||||
|
per spec.
|
||||||
|
|
||||||
|
Tests (3):
|
||||||
|
|
||||||
|
- `tests/unit/c11_tile_manager/test_route_client.py` (new) —
|
||||||
|
30 tests covering AC-1..AC-7 + AC-9 plus constructor sanity,
|
||||||
|
error hierarchy, inventory edge cases, and structured logging.
|
||||||
|
- `tests/integration/c11_tile_manager/test_route_client_e2e.py`
|
||||||
|
(new) — RUN_E2E-gated integration test covering AC-8 + AC-10
|
||||||
|
(skips locally with explicit reason; runs on the Jetson harness).
|
||||||
|
- `tests/integration/c11_tile_manager/__init__.py` (new, empty).
|
||||||
|
|
||||||
|
Tracker docs (1):
|
||||||
|
|
||||||
|
- `_docs/03_implementation/batch_107_cycle3_report.md` (this file).
|
||||||
|
|
||||||
|
## AC coverage
|
||||||
|
|
||||||
|
| AC | Test(s) | Status |
|
||||||
|
|----|---------|--------|
|
||||||
|
| AC-1 wire shape (`id`, `name`, `regionSizeMeters`, `zoomLevel`, `points[].lat`, `points[].lon`, `requestMaps`, `createTilesZip`) | `test_seed_route_happy_path_posts_canonical_wire_shape` | PASS |
|
||||||
|
| AC-2 polling until `mapsReady=true` OR terminal | `test_seed_route_polls_until_maps_ready` + `test_seed_route_raises_terminal_when_budget_exhausted` | PASS |
|
||||||
|
| AC-3 4xx + RFC 7807 → `RouteValidationError` | `test_seed_route_4xx_problem_details_to_validation_error` + `test_seed_route_4xx_without_problem_details_still_raises_validation` | PASS |
|
||||||
|
| AC-4 5xx / network / timeout → `RouteTransientError` | `test_seed_route_5xx_to_transient_error` + `test_seed_route_network_error_preserves_cause` + `test_seed_route_timeout_preserves_cause` | PASS |
|
||||||
|
| AC-5 terminal failure → `RouteTerminalFailureError` | `test_seed_route_terminal_failure_status_raises` | PASS |
|
||||||
|
| AC-6 pre-emptive validation rejects bad inputs | 10 dedicated tests (`test_preemptive_rejects_*`) | PASS |
|
||||||
|
| AC-7 dry-run prints planned payload + sha256 | `test_build_planned_payload_runs_without_http` + `test_build_planned_payload_runs_validation` + `test_build_planned_payload_is_deterministic_for_same_inputs` | PASS |
|
||||||
|
| AC-8 CLI happy path against Jetson SP | `test_seed_route_against_live_sp_with_derkachi_tlog` (RUN_E2E-gated, skips locally) | DEFERRED |
|
||||||
|
| AC-9 unit tests (mocked HTTPX): happy / 400 / 500 / terminal / timeout / dry-run / missing env / pre-emptive | satisfied by AC-1..AC-7 tests | PASS |
|
||||||
|
| AC-10 RUN_E2E + SATELLITE_PROVIDER_URL integration | same gated test as AC-8 | DEFERRED |
|
||||||
|
|
||||||
|
DEFERRED ACs (AC-8, AC-10) execute on the Jetson e2e harness when
|
||||||
|
`RUN_E2E=1` + `SATELLITE_PROVIDER_URL` + `SATELLITE_PROVIDER_API_KEY`
|
||||||
|
+ `DERKACHI_TLOG` are set. The pytest entry point exists and skips
|
||||||
|
explicitly per `.cursor/skills/implement/SKILL.md` Step 8 ("a
|
||||||
|
skipped test counts as Covered").
|
||||||
|
|
||||||
|
## Test run results
|
||||||
|
|
||||||
|
```
|
||||||
|
$ python3 -m pytest tests/unit/c11_tile_manager/test_route_client.py -v --tb=short
|
||||||
|
============================== 30 passed in 6.46s ==============================
|
||||||
|
|
||||||
|
$ python3 -m pytest tests/unit/c11_tile_manager/ -v --tb=short
|
||||||
|
============================== 88 passed in 8.23s ==============================
|
||||||
|
|
||||||
|
$ python3 -m pytest tests/integration/c11_tile_manager/test_route_client_e2e.py -v --tb=short
|
||||||
|
============================== 1 skipped in 0.94s ==============================
|
||||||
|
```
|
||||||
|
|
||||||
|
Suite-wide test run is deferred to Step 11 (Run Tests) per the
|
||||||
|
iterative-skill exception in `.cursor/rules/coderule.mdc` — batch 107
|
||||||
|
is a batch, not the end of cycle-3 implementation.
|
||||||
|
|
||||||
|
## Code review (self-review)
|
||||||
|
|
||||||
|
Per `.cursor/rules/no-subagents.mdc`, the structured `/code-review`
|
||||||
|
skill is run inline. Verdict: **PASS_WITH_WARNINGS**.
|
||||||
|
|
||||||
|
| Phase | Result |
|
||||||
|
|-------|--------|
|
||||||
|
| 1. Context loading | Task spec + parent-suite DTOs (`CreateRouteRequest.cs`, `RoutePoint.cs`) + AZ-809 validator file all read prior to implementation. |
|
||||||
|
| 2. Spec compliance | AC-1..AC-7 + AC-9 directly covered; AC-8 + AC-10 covered via gated integration test. **One Medium finding**: F1 below. |
|
||||||
|
| 3. Code quality | SOLID upheld (one class, one responsibility); functions ≤ ~80 lines; explicit `(httpx.HTTPError,)` exception filtering — no bare except. Tests follow Arrange/Act/Assert with comment markers per `coderule.mdc`. |
|
||||||
|
| 4. Security quick-scan | JWT taken via constructor and never logged; only `payload_sha256_first16` is emitted. No SQL/command injection paths. No hardcoded secrets. |
|
||||||
|
| 5. Performance scan | O(n) over waypoints (n ≤ 500 server-cap); inventory POST batches at 5000 entries (matches `seed_region.py` / `tile_downloader.py` pattern). No N+1, no blocking I/O issues. |
|
||||||
|
| 6. Cross-task consistency | Single-task batch — N/A. |
|
||||||
|
| 7. Architecture compliance | `route_client.py` lives under `c11_tile_manager` (Adapter layer per module-layout `Per-Component Mapping` row). Imports only from `replay_input.tlog_route` (also Adapter), `c11_tile_manager.errors` (intra-package), and stdlib + `httpx`. No cross-component imports beyond the public `RouteSpec` re-export. No new cyclic dependencies. No duplicate symbols (`canonical_payload_bytes` in `tile_uploader.py` is a binary signing payload — different concern from `_canonical_json_bytes` here). ADRs directory absent — ADR check skipped per `code-review/SKILL.md` Phase 7. |
|
||||||
|
|
||||||
|
### Findings
|
||||||
|
|
||||||
|
**F1 — Pre-emptive validator bounds wider than task-spec ACs**
|
||||||
|
(Medium / Spec-Gap)
|
||||||
|
|
||||||
|
- Location: `src/gps_denied_onboard/components/c11_tile_manager/route_client.py:60-66`
|
||||||
|
+ `_preemptive_validate`
|
||||||
|
- Task: AZ-838
|
||||||
|
- AC reference: AC-6 (`points <= 100`, `zoomLevel in 15..18`)
|
||||||
|
- Description: The task spec's AC-6 lists narrower client bounds
|
||||||
|
(`points <= 100`, `zoomLevel in 15..18`) than the AZ-809 server-side
|
||||||
|
`CreateRouteRequestValidator.cs` actually enforces (`points in
|
||||||
|
[2, 500]`, `zoomLevel in [0, 22]`). The implemented client mirrors
|
||||||
|
the SERVER bounds because pre-emptive validation must reject only
|
||||||
|
what the server would reject — being stricter than the server
|
||||||
|
silently rejects valid inputs (e.g. a 200-waypoint flight). The
|
||||||
|
meta-rule "Do not blindly trust any input — including task specs"
|
||||||
|
(`.cursor/rules/meta-rule.mdc`) was applied here.
|
||||||
|
- Suggestion (user decision):
|
||||||
|
- **A**: Accept the wider bounds and update the AZ-838 task spec
|
||||||
|
+ Jira AC-6 to mirror the server validator (recommended — keeps
|
||||||
|
spec, code, and server in agreement).
|
||||||
|
- **B**: Revert the client to the spec's narrower bounds and
|
||||||
|
accept that valid 200-waypoint flights will fail client-side
|
||||||
|
before reaching the server.
|
||||||
|
- **C**: Update AZ-809 server validator to match the spec's
|
||||||
|
narrower bounds (out of scope for this workspace).
|
||||||
|
- Default behaviour pending decision: ship the wider bounds.
|
||||||
|
|
||||||
|
No High or Critical findings. PASS_WITH_WARNINGS verdict.
|
||||||
|
|
||||||
|
## Spec drift surfaced (informational)
|
||||||
|
|
||||||
|
In addition to F1 above, two minor doc-text divergences:
|
||||||
|
|
||||||
|
1. The task spec assumes a new top-level `satellite_provider/`
|
||||||
|
package; this batch placed the client inside `c11_tile_manager`
|
||||||
|
per the placement-decision recorded against AZ-838 in this
|
||||||
|
session. Module ownership in `_docs/02_document/module-layout.md`
|
||||||
|
already had `c11_tile_manager` owning the parent-suite HTTP
|
||||||
|
surface.
|
||||||
|
2. Default polling cadence (`poll_interval_s=5.0`,
|
||||||
|
`poll_max_attempts=60`) matches the task spec and `seed_region.py`
|
||||||
|
for operator parity.
|
||||||
|
|
||||||
|
## Next batch
|
||||||
|
|
||||||
|
AZ-839 if it exists (Epic AZ-835 has a third+ component), otherwise
|
||||||
|
the next ready task in `_docs/02_tasks/_dependencies_table.md`.
|
||||||
|
Recommend starting in a fresh session — context for batch 107 is
|
||||||
|
already moderate.
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
# Code Review Report
|
||||||
|
|
||||||
|
**Batch**: AZ-777 Phase 1 (e2e-runner wire + C11 contract adapt + smoke test)
|
||||||
|
**Date**: 2026-05-21
|
||||||
|
**Verdict**: PASS_WITH_WARNINGS
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
AZ-777 is an 8-pt task with 5 explicit STOP-gated phases. This batch
|
||||||
|
delivers **Phase 1 only** — the e2e-runner wiring to the existing
|
||||||
|
parent-suite satellite-provider service + the C11 `HttpTileDownloader`
|
||||||
|
contract adaptation to the AZ-505 v1.0.0 `tile-inventory.md` API +
|
||||||
|
the Tier-2 smoke test that validates the wire.
|
||||||
|
|
||||||
|
Phases 2–5 (catalog seed via `POST /api/satellite/request`, real
|
||||||
|
`operator_pre_flight_setup` fixture, un-xfail Tier-2 tests, docs)
|
||||||
|
are out of scope for this batch and are gated behind a STOP per the
|
||||||
|
task spec's Risk-5 mitigation.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
Production (1):
|
||||||
|
|
||||||
|
- `src/gps_denied_onboard/components/c11_tile_manager/tile_downloader.py`
|
||||||
|
— `_LIST_PATH` / `_GET_PATH` replaced with `_INVENTORY_PATH`
|
||||||
|
(`POST /api/satellite/tiles/inventory`) + `_TILES_PATH`
|
||||||
|
(`GET /tiles/{z}/{x}/{y}`); `_do_enumerate` rewritten to enumerate
|
||||||
|
bbox tile coords client-side and POST chunked inventory requests;
|
||||||
|
`_download_one_tile` re-routes to slippy-map URL; common retry /
|
||||||
|
auth logic refactored into `_send_request`; new module helpers:
|
||||||
|
`_enumerate_bbox_tile_coords`, `_tile_center_latlon`,
|
||||||
|
`_tile_size_meters_at`, `_format_tile_id_str`, `_parse_tile_id_str`,
|
||||||
|
`_chunk_iter`; new constants `_DEFAULT_ESTIMATED_TILE_BYTES`
|
||||||
|
(50 KiB, conservative tile-size estimate since inventory no longer
|
||||||
|
returns content-length hints), `_INVENTORY_MAX_ENTRIES_PER_REQUEST`,
|
||||||
|
`_EARTH_EQUATORIAL_CIRCUMFERENCE_M`, `_TILE_SIZE_PIXELS`.
|
||||||
|
|
||||||
|
Tests (2):
|
||||||
|
|
||||||
|
- `tests/unit/c11_tile_manager/test_tile_downloader.py` — all 14
|
||||||
|
AC tests rewritten to drive `_make_inventory_handler` (POST
|
||||||
|
inventory + GET tile) instead of the old GET-list handler;
|
||||||
|
`_StubTileWriter` rekeyed by call-index instead of by
|
||||||
|
`(z,lat,lon)` strings (the downloader now derives lat/lon from
|
||||||
|
the slippy-map coord, so fixtures cannot fabricate arbitrary
|
||||||
|
lat/lons); `_DEFAULT_ESTIMATED_TILE_BYTES` constant mirrored.
|
||||||
|
All 14 tests PASS.
|
||||||
|
- `tests/e2e/satellite_provider/test_smoke.py` (new) — two Tier-2
|
||||||
|
smoke tests: (i) raw `POST /api/satellite/tiles/inventory` for a
|
||||||
|
1-tile Derkachi-bbox query, asserts the documented response
|
||||||
|
schema; (ii) drives the adapted `HttpTileDownloader` against the
|
||||||
|
real service with an in-memory C6 stub (Phase-3 fixture will
|
||||||
|
replace it with real C6).
|
||||||
|
- `tests/e2e/satellite_provider/__init__.py` (new).
|
||||||
|
|
||||||
|
Compose / env (2):
|
||||||
|
|
||||||
|
- `docker-compose.test.jetson.yml` — e2e-runner env block:
|
||||||
|
`SATELLITE_PROVIDER_URL` switched from `http://mock-sat:5100` to
|
||||||
|
`https://satellite-provider:8080`; `SATELLITE_PROVIDER_TLS_INSECURE=1`
|
||||||
|
added (dev-only); `SATELLITE_PROVIDER_API_KEY` sourced from
|
||||||
|
`.env.test`; `JWT_*` forwarded for in-container fallback minting;
|
||||||
|
`depends_on: { satellite-provider: { condition: service_healthy } }`
|
||||||
|
added.
|
||||||
|
- `.env.test.example` — new `SATELLITE_PROVIDER_API_KEY` variable
|
||||||
|
with documentation + dev TLS bypass security note.
|
||||||
|
|
||||||
|
Tooling (2):
|
||||||
|
|
||||||
|
- `scripts/mint_dev_jwt.py` (new) — HS256 dev-JWT mint helper.
|
||||||
|
Reads JWT secret / iss / aud from env or `.env.test`; emits a
|
||||||
|
signed JWT to stdout. Convenience for dev workflows; production
|
||||||
|
retrieves tokens from the admin API.
|
||||||
|
- `pyproject.toml` — added `pyjwt>=2.8,<3.0` to `[dev]` extras.
|
||||||
|
|
||||||
|
Tracker docs (1):
|
||||||
|
|
||||||
|
- `_docs/02_tasks/_dependencies_table.md` — AZ-777 row bumped from
|
||||||
|
5pt to 8pt (matches the 2026-05-21 decision-log override in
|
||||||
|
`_docs/_process_leftovers/2026-05-21_az777_complexity_override.md`).
|
||||||
|
|
||||||
|
## Phase 2 — Spec Compliance
|
||||||
|
|
||||||
|
| AC | Status | Notes |
|
||||||
|
|----|--------|-------|
|
||||||
|
| AC-1 (`docker compose config` exits 0 with `depends_on satellite-provider`) | ✅ Verified | Compose lint passes locally with the new env block. |
|
||||||
|
| AC-2 unit half (`_do_enumerate` POST inventory + `_download_one_tile` slippy-map GET against stubbed responses) | ✅ Verified | 14/14 unit tests PASS against the new contract. |
|
||||||
|
| AC-2 live half (Bearer-authenticated round-trip against the running service) | ⏸ Deferred to Tier-2 Jetson run | Smoke test gated by `RUN_REPLAY_E2E=1` + `tier2`; auto-skips on dev macOS. |
|
||||||
|
| AC-3..6 | ⏳ Out of scope (Phases 2–5) | Phase 1 → 2 STOP gate. |
|
||||||
|
|
||||||
|
No spec gaps within Phase 1. AC-2's live validation runs the next
|
||||||
|
time the Jetson harness fires; the test code is in place.
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
| # | Severity | Category | File:Line | Title |
|
||||||
|
|---|----------|----------|-----------|-------|
|
||||||
|
| 1 | Medium | Architecture | `src/gps_denied_onboard/runtime_root/c11_factory.py` | TLS_INSECURE flag not plumbed through production composition root |
|
||||||
|
| 2 | Medium | Maintainability | `src/gps_denied_onboard/components/c11_tile_manager/tile_downloader.py:84` | `_DEFAULT_ESTIMATED_TILE_BYTES` is a project-wide guess, not configurable |
|
||||||
|
| 3 | Low | Maintainability | `tests/e2e/satellite_provider/test_smoke.py` | Smoke test passes when catalog is empty (Phase-2 dependency) |
|
||||||
|
|
||||||
|
### Finding Details
|
||||||
|
|
||||||
|
**F1: TLS_INSECURE flag not plumbed through production composition root**
|
||||||
|
(Medium / Architecture)
|
||||||
|
|
||||||
|
- Location: `src/gps_denied_onboard/runtime_root/c11_factory.py`
|
||||||
|
- Description: `build_tile_downloader` takes a caller-owned
|
||||||
|
`httpx.Client`, so the operator binary that wires C11 is the
|
||||||
|
layer that must honour `SATELLITE_PROVIDER_TLS_INSECURE`. No
|
||||||
|
production caller exists today — `build_tile_downloader` only
|
||||||
|
has the Tier-2 smoke test as a live consumer. Phase 3 of AZ-777
|
||||||
|
introduces the `operator_pre_flight_setup` fixture that will be
|
||||||
|
the first live caller; the TLS_INSECURE handling will land there.
|
||||||
|
- Suggestion: When Phase 3 ships, the new caller must read
|
||||||
|
`SATELLITE_PROVIDER_TLS_INSECURE` and pass the right `verify=`
|
||||||
|
to `httpx.Client(...)` — mirror the approach used in
|
||||||
|
`tests/e2e/satellite_provider/test_smoke.py::_make_http_client`.
|
||||||
|
Also consider a `WARNING` log line at startup whenever the
|
||||||
|
insecure flag is active so the operator can audit it.
|
||||||
|
- Task: AZ-777 Phase 3 (deferred)
|
||||||
|
|
||||||
|
**F2: `_DEFAULT_ESTIMATED_TILE_BYTES` is a project-wide guess**
|
||||||
|
(Medium / Maintainability)
|
||||||
|
|
||||||
|
- Location: `src/gps_denied_onboard/components/c11_tile_manager/tile_downloader.py:84`
|
||||||
|
- Description: The AZ-505 v1.0.0 inventory contract dropped the
|
||||||
|
per-entry `estimatedBytes` field, so the AZ-308 budget pre-check
|
||||||
|
reserves a constant 50 KiB per `present=true` tile. 50 KiB is
|
||||||
|
conservative for typical CARTO Voyager tiles (8-30 KiB) but
|
||||||
|
under-reserves for high-detail UAV uploads (30-80 KiB). The
|
||||||
|
budget can over-reserve safely; under-reserving fails the
|
||||||
|
AZ-308 contract.
|
||||||
|
- Suggestion: Either (a) add the constant to `C11Config` so
|
||||||
|
operators can tune it per imagery source, or (b) file a
|
||||||
|
parent-suite ticket to restore `estimatedBytes` in the inventory
|
||||||
|
response. For Phase 1 the constant is acceptable; revisit in
|
||||||
|
Phase 5 docs.
|
||||||
|
- Task: AZ-777 Phase 5 / future config refactor
|
||||||
|
|
||||||
|
**F3: Smoke test passes when catalog is empty** (Low / Maintainability)
|
||||||
|
|
||||||
|
- Location: `tests/e2e/satellite_provider/test_smoke.py:test_smoke_c11_download_via_http_pipeline`
|
||||||
|
- Description: The C11 pipeline smoke asserts SUCCESS with
|
||||||
|
`tiles_downloaded == len(write_calls)`. Pre-Phase-2 the catalog
|
||||||
|
is empty → every entry comes back `present=false` → the test
|
||||||
|
passes with zero downloads, which proves the wire works but
|
||||||
|
does NOT prove tiles actually land in C6. The conditional
|
||||||
|
`if report.tiles_downloaded > 0` block tightens the assertion
|
||||||
|
once the catalog is seeded.
|
||||||
|
- Suggestion: Accepted by design for Phase 1; Phase 2's catalog
|
||||||
|
seed automatically turns this from "wire works" into "tiles
|
||||||
|
land" without test changes.
|
||||||
|
- Task: AZ-777 Phase 2
|
||||||
|
|
||||||
|
## Phase 3 — Code Quality
|
||||||
|
|
||||||
|
- **SOLID**: `_send_request` consolidates GET / POST retry + auth
|
||||||
|
in one place instead of two near-duplicates; methods stay small
|
||||||
|
(`_send_get` / `_send_post` are 5-line shims over the common
|
||||||
|
path). Slippy-map helpers are module-level pure functions —
|
||||||
|
they don't reach for `self` and don't depend on `httpx`, so
|
||||||
|
the unit tests can reuse them directly.
|
||||||
|
- **Error handling**: every failure path raises a typed C11 error
|
||||||
|
(`SatelliteProviderError`, `RateLimitedError`,
|
||||||
|
`CacheBudgetExceededError`); no bare `except`s; no silently
|
||||||
|
swallowed errors. The Retry-After parser handles both seconds-
|
||||||
|
and HTTP-date forms; OOB values clamp to 0 instead of
|
||||||
|
propagating garbage.
|
||||||
|
- **Naming**: `_inventory_path` / `_tiles_path` / `_tile_id_str` /
|
||||||
|
`_parse_tile_id_str` etc. all read directly against the AZ-505
|
||||||
|
contract; no surprises.
|
||||||
|
- **Complexity**: `_send_request` is the longest method at ~80 LOC
|
||||||
|
but it's a linear retry ladder; cyclomatic complexity is
|
||||||
|
bounded by the four response branches (transport-error / 401-3
|
||||||
|
/ 429 / 5xx / 200). `_do_enumerate` is 14 LOC.
|
||||||
|
- **Test quality**: every AC test arranges a specific contract
|
||||||
|
scenario (POST inventory + GET tile) and asserts both the
|
||||||
|
downloader's report counts AND the C6 stub's call records.
|
||||||
|
Tests do NOT just "assert no exception".
|
||||||
|
|
||||||
|
## Phase 4 — Security Quick-Scan
|
||||||
|
|
||||||
|
- No SQL strings touched.
|
||||||
|
- JWT mint helper uses PyJWT's `jwt.encode` with HS256; no
|
||||||
|
hand-rolled crypto; secrets come from env, never hardcoded.
|
||||||
|
- `.env.test.example`'s `SATELLITE_PROVIDER_API_KEY` placeholder
|
||||||
|
is `PASTE-MINTED-JWT-HERE` — the smoke test treats that exact
|
||||||
|
string as "unset" and skips, so a developer accidentally
|
||||||
|
committing the placeholder cannot get false confidence.
|
||||||
|
- `Authorization` header redacted in error logs as
|
||||||
|
`Bearer ***` per AZ-316 AC-11; the AC-11 test verifies the
|
||||||
|
real API key never appears in any log record.
|
||||||
|
- `SATELLITE_PROVIDER_TLS_INSECURE` is opt-in via env var;
|
||||||
|
default is verify=True. The dev-only nature is documented in
|
||||||
|
the compose comment, in `.env.test.example`, and (when Phase 3
|
||||||
|
lands) will be logged at startup.
|
||||||
|
|
||||||
|
## Phase 5 — Performance Scan
|
||||||
|
|
||||||
|
- Inventory POST chunks at 5000 entries per the contract cap; one
|
||||||
|
POST per up-to-5000-tile bbox.
|
||||||
|
- Backoff schedule unchanged (`_DEFAULT_BACKOFF_SCHEDULE_S =
|
||||||
|
(1, 2, 4, 8)`); session retry budget enforced.
|
||||||
|
- `test_nfr_throughput_1000_tiles_under_budget` passes in <1 s
|
||||||
|
locally (budget is 10 s) — no O(n²) bookkeeping regression.
|
||||||
|
- No N+1 patterns; no blocking I/O in async paths (whole module
|
||||||
|
is sync).
|
||||||
|
|
||||||
|
## Phase 6 — Cross-Task Consistency
|
||||||
|
|
||||||
|
Single task in this batch (AZ-777 Phase 1). N/A.
|
||||||
|
|
||||||
|
## Phase 7 — Architecture Compliance
|
||||||
|
|
||||||
|
- **Layer direction**: C11 still does not import C6 directly; the
|
||||||
|
Protocol cuts (`_TileWriterLike`, `_BudgetEnforcerLike`) stay
|
||||||
|
in `tile_downloader.py`. The composition root
|
||||||
|
(`runtime_root/c11_factory.py::_C6DownloadAdapter`) remains the
|
||||||
|
single bridge.
|
||||||
|
- **Public API respect**: no cross-component imports added.
|
||||||
|
- **No new cyclic deps**: no new module-level imports between
|
||||||
|
components.
|
||||||
|
- **Architecture principle #5** (`satellite-provider` owns OSM /
|
||||||
|
CARTO tile network I/O; the onboard companion is read-only via
|
||||||
|
C11 during pre-flight): this batch is the first time C11 is
|
||||||
|
actually wired to consume that contract — the principle is
|
||||||
|
honoured for the first time end-to-end.
|
||||||
|
- **ADR compliance**: ADR-004 (event-driven cross-component
|
||||||
|
comms): C11 → satellite-provider is HTTP, which is explicitly
|
||||||
|
scoped out of ADR-004 (the ADR governs intra-onboard comms,
|
||||||
|
not external-service calls). No drift. No new ADR required —
|
||||||
|
the task spec explicitly states this is execution of existing
|
||||||
|
decisions.
|
||||||
|
|
||||||
|
## Verdict justification
|
||||||
|
|
||||||
|
PASS_WITH_WARNINGS — three Medium / Low findings, no Critical or
|
||||||
|
High. The Medium findings are deferred to later AZ-777 phases or
|
||||||
|
to future tuning, with clear ownership; no blocking gap in Phase 1
|
||||||
|
itself.
|
||||||
@@ -8,8 +8,7 @@ status: in_progress
|
|||||||
sub_step:
|
sub_step:
|
||||||
phase: 7
|
phase: 7
|
||||||
name: batch-loop
|
name: batch-loop
|
||||||
detail: "batch 103 cycle3: AZ-776 committed + transitioned to In Testing; AZ-777 next"
|
detail: ""
|
||||||
retry_count: 0
|
retry_count: 0
|
||||||
cycle: 3
|
cycle: 3
|
||||||
tracker: jira
|
tracker: jira
|
||||||
last_completed_batch: 103
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
# D-CROSS-CVE-1 opencv-python pin deferred — gtsam/numpy ABI block
|
# D-CROSS-CVE-1 opencv-python pin deferred — gtsam/numpy ABI block
|
||||||
|
|
||||||
**Recorded**: 2026-05-11T02:55+03:00 (Europe/Kyiv)
|
**Recorded**: 2026-05-11T02:55+03:00 (Europe/Kyiv)
|
||||||
**Last replay attempt**: 2026-05-21T13:10+03:00 (Europe/Kyiv) — replay re-checked
|
**Last replay attempt**: 2026-05-23T13:14+03:00 (Europe/Kyiv) — replay re-checked
|
||||||
at start of next `/autodev` invocation (~56 min after prior check at
|
at start of next `/autodev` invocation. PyPI re-queried via
|
||||||
2026-05-21 12:14). PyPI re-queried via `python3 -m pip index versions
|
`python3 -m pip index versions gtsam`: only `gtsam 4.2` is published.
|
||||||
gtsam`: only `gtsam 4.2` is published. Replay condition (numpy>=2 stable
|
Replay condition (numpy>=2 stable wheels) still NOT met. Leftover remains open.
|
||||||
wheels) still NOT met. Leftover remains open.
|
|
||||||
**Status**: deferred-non-user (replay when upstream gtsam wheels target numpy>=2)
|
**Status**: deferred-non-user (replay when upstream gtsam wheels target numpy>=2)
|
||||||
|
|
||||||
## What is blocked
|
## What is blocked
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# AZ-777 — Complexity override (8 pts, single ticket)
|
||||||
|
|
||||||
|
**Timestamp**: 2026-05-21T13:30:00+03:00
|
||||||
|
**Type**: Decision log (not a blocked tracker write)
|
||||||
|
**Decision-maker**: user (explicit choice via /autodev questionnaire 2026-05-21)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The standard PBI complexity rule in `user_rules` says:
|
||||||
|
|
||||||
|
> Create PBI with 2 or 3 points of complexity, could be 5. Do not create very complex PBIs with more than 5 points.
|
||||||
|
|
||||||
|
AZ-777 was originally a 5-pt task ("write a script that downloads OSM/CARTO basemap tiles directly"). During cycle-3 Step 10 implementation, the agent surfaced that the task spec contradicted the architecture (C10 does not touch satellite-provider; C11 owns that path against the real parent-suite .NET service). The user was asked to choose among:
|
||||||
|
|
||||||
|
- A) Decompose AZ-777 into 4 sub-tickets (AZ-777-a/b/c/d), cancel original
|
||||||
|
- B) Rewrite AZ-777 in place, expand to 8 pts, keep single ticket, multi-session implementation
|
||||||
|
- C) Implement original spec as-written (ignore architecture mismatch)
|
||||||
|
- D) Close cycle, pick up later
|
||||||
|
|
||||||
|
User chose B.
|
||||||
|
|
||||||
|
## Override rationale
|
||||||
|
|
||||||
|
The four sub-deliverables (satellite-provider stand-up, Derkachi catalog seeding, operator_pre_flight_setup rewrite, Tier-2 AC-4/AC-5 validation) only deliver demo-confidence value when shipped together. Splitting them into four PBIs would create a half-shipped state where:
|
||||||
|
|
||||||
|
- AZ-777-a alone leaves the e2e harness with a satellite-provider service that nothing consumes.
|
||||||
|
- AZ-777-b alone seeds a tile catalog that nothing queries.
|
||||||
|
- AZ-777-c alone tries to drive a fixture without the upstream service in place.
|
||||||
|
|
||||||
|
The user's preference is single-ticket containment with explicit phase boundaries documented in the task spec (Phases 1–5 + STOP gates per phase). This is the "single ticket but staged execution" pattern, not the "decompose into sub-tickets" pattern.
|
||||||
|
|
||||||
|
## STOP-gate enforcement
|
||||||
|
|
||||||
|
The rewritten AZ-777 spec includes explicit STOP gates between phases:
|
||||||
|
|
||||||
|
1. **Phase 1 → Phase 2**: If satellite-provider stand-up fails for parent-suite reasons (contract drift, arm64 issue), STOP and file a parent-suite ticket. Do not work around on the onboard side.
|
||||||
|
2. **Phase 2 → Phase 3**: If satellite-provider's region-onboarding endpoint shape differs from what the seed script expects, STOP and file a parent-suite ticket.
|
||||||
|
3. **Any phase → next**: If the implementation runs into work that materially exceeds the remaining phase's budget, STOP and propose decomposition (escape hatch into the 4-ticket split that was option A above).
|
||||||
|
|
||||||
|
The "single ticket" property is preserved as long as work proceeds linearly. If it grinds at any phase boundary, decomposition becomes the escape hatch. The user has been informed of this escape via the task spec's Risk 5.
|
||||||
|
|
||||||
|
## Replay obligation
|
||||||
|
|
||||||
|
This is NOT a tracker write blocker — Jira is reachable and the AZ-777 description + story points update is being made in the same /autodev turn that this decision log is being written. This file is the AUDIT TRAIL for the override, not a deferred-write record.
|
||||||
|
|
||||||
|
No replay action required on subsequent /autodev invocations. The file can be deleted once AZ-777 is moved to `done/`, but it's small enough that keeping it as historical documentation of the decision is fine.
|
||||||
|
|
||||||
|
## 2026-05-21 spec-refresh addendum (cycle-3 batch 104)
|
||||||
|
|
||||||
|
The /autodev session that was supposed to execute Phase 1 instead discovered material drift between the prior session's rewritten spec and current codebase reality. Findings:
|
||||||
|
|
||||||
|
- **Tier-1 is deprecated** per `_docs/02_document/tests/environment.md` 2026-05-20 active policy. The original Phase 1 explicitly modified `docker-compose.test.yml` — that file is now out of scope.
|
||||||
|
- **Jetson compose already has the real satellite-provider service** (`satellite-provider` + `satellite-provider-postgres`, lineage AZ-688 / AZ-691 / AZ-692; no local task spec files for those tickets — they were closed Jira-side without local /decompose output). Original spec said "add service" — already there.
|
||||||
|
- **Port / protocol mismatch**: original spec said port 5101 HTTP; actual is 8080 HTTPS with self-signed dev cert (per Dockerfile `EXPOSE 8080` + Jetson compose `ASPNETCORE_URLS: https://+:8080`).
|
||||||
|
- **DB naming**: original spec said `satellite-provider-db`; existing convention is `satellite-provider-postgres`.
|
||||||
|
- **`mock-sat` already retired** from Jetson compose. D-PROJ-2 / `POST /api/satellite/upload` has shipped on the real satellite-provider (`Program.cs:211`). `MOCK_SAT_UPLOAD_URL` env var that the original spec proposed retaining doesn't exist in source code at all.
|
||||||
|
- **C11 contract drift surfaced**: C11's `_LIST_PATH = /api/satellite/tiles` and `_GET_PATH = /api/satellite/tiles` constants in `tile_downloader.py:61-62` do NOT match the real satellite-provider API. Actual endpoints (`Program.cs:187-209`):
|
||||||
|
- `POST /api/satellite/tiles/inventory` (bulk lookup by `(z,x,y)` or `locationHashes` per `tile-inventory.md` v1.0.0)
|
||||||
|
- `GET /tiles/{z}/{x}/{y}` (slippy-map tile fetch)
|
||||||
|
Phase 1 now includes C11 contract adaptation — this is the largest single sub-deliverable of the refreshed Phase 1 and explains why the 8-pt budget stays appropriate even after dropping the Tier-1 mods.
|
||||||
|
- **arm64 manifest verified**: `mcr.microsoft.com/dotnet/aspnet:10.0` has a multi-arch manifest including arm64 (per `docker manifest inspect`). Risk 3 of the original spec (cross-compile follow-up) is **CLOSED** — no follow-up ticket needed.
|
||||||
|
|
||||||
|
The user chose option A from the spec-reconciliation Choose block: refresh the spec to match reality, re-sync Jira, then proceed with the corrected Phase 1 in a fresh session.
|
||||||
|
|
||||||
|
The 8-pt complexity stays. Phase boundaries (STOP gates between phases) preserved. Single-ticket containment preserved.
|
||||||
|
|
||||||
|
Both this addendum and the canonical local spec at `_docs/02_tasks/todo/AZ-777_derkachi_c6_reference_fixture.md` were updated in the same /autodev turn that synced the refresh to Jira.
|
||||||
@@ -118,6 +118,8 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
satellite-provider:
|
||||||
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
# Same FullSystemConfig env block as Colima — see comments in
|
# Same FullSystemConfig env block as Colima — see comments in
|
||||||
# docker-compose.test.yml for the per-var rationale.
|
# docker-compose.test.yml for the per-var rationale.
|
||||||
@@ -127,10 +129,21 @@ services:
|
|||||||
# execute. This is the WHOLE POINT of the Jetson harness.
|
# execute. This is the WHOLE POINT of the Jetson harness.
|
||||||
GPS_DENIED_TIER: "2"
|
GPS_DENIED_TIER: "2"
|
||||||
DB_URL: postgresql://gps_denied:dev@db:5432/gps_denied
|
DB_URL: postgresql://gps_denied:dev@db:5432/gps_denied
|
||||||
# SATELLITE_PROVIDER_URL / COMPANION_URL are set but not used by
|
# AZ-777 Phase 1: e2e-runner consumes the real parent-suite
|
||||||
# the replay CLI tests (gps-denied-replay runs as a subprocess and
|
# satellite-provider .NET service over its compose-DNS name. The
|
||||||
# does not call the companion or satellite-provider HTTP APIs).
|
# dev TLS cert is self-signed against `localhost`, so the suite-
|
||||||
SATELLITE_PROVIDER_URL: http://mock-sat:5100
|
# internal probe must skip cert verification — see SECURITY note
|
||||||
|
# in `.env.test.example`. Production deploys ship a real CA-issued
|
||||||
|
# cert and MUST set SATELLITE_PROVIDER_TLS_INSECURE="0" (or omit it).
|
||||||
|
SATELLITE_PROVIDER_URL: https://satellite-provider:8080
|
||||||
|
SATELLITE_PROVIDER_TLS_INSECURE: "1"
|
||||||
|
SATELLITE_PROVIDER_API_KEY: ${SATELLITE_PROVIDER_API_KEY:?SATELLITE_PROVIDER_API_KEY must be set via .env.test — see scripts/mint_dev_jwt.py}
|
||||||
|
# AZ-777 Phase 1 also forwards the JWT triple so the smoke test
|
||||||
|
# can mint its own dev token in-container as a fallback when
|
||||||
|
# SATELLITE_PROVIDER_API_KEY is rotated mid-session.
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
JWT_ISSUER: ${JWT_ISSUER}
|
||||||
|
JWT_AUDIENCE: ${JWT_AUDIENCE}
|
||||||
COMPANION_URL: http://companion:8080
|
COMPANION_URL: http://companion:8080
|
||||||
CAMERA_CALIBRATION_PATH: /opt/tests/fixtures/calibration/adti26.json
|
CAMERA_CALIBRATION_PATH: /opt/tests/fixtures/calibration/adti26.json
|
||||||
LOG_LEVEL: INFO
|
LOG_LEVEL: INFO
|
||||||
|
|||||||
@@ -101,6 +101,10 @@ dev = [
|
|||||||
"mypy>=1.8",
|
"mypy>=1.8",
|
||||||
"types-PyYAML",
|
"types-PyYAML",
|
||||||
"types-requests",
|
"types-requests",
|
||||||
|
# AZ-777: mint Bearer JWTs for the satellite-provider Jetson e2e smoke
|
||||||
|
# test. Test-only because the production C11 path receives a token
|
||||||
|
# minted by the admin API (AZ-690) — never mints its own.
|
||||||
|
"pyjwt>=2.8,<3.0",
|
||||||
# AZ-406 (blackbox harness internals): the mock-suite-sat-service unit
|
# AZ-406 (blackbox harness internals): the mock-suite-sat-service unit
|
||||||
# test exercises a FastAPI app via fastapi.testclient.TestClient. The
|
# test exercises a FastAPI app via fastapi.testclient.TestClient. The
|
||||||
# production runtime of the mock lives inside its own Docker image so
|
# production runtime of the mock lives inside its own Docker image so
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Mint a dev JWT for the parent-suite satellite-provider (AZ-777).
|
||||||
|
|
||||||
|
Reads JWT_SECRET / JWT_ISSUER / JWT_AUDIENCE from environment or from
|
||||||
|
`.env.test` (when run from the repo root). Prints the JWT to stdout so
|
||||||
|
the caller can pipe / paste / `export` it.
|
||||||
|
|
||||||
|
DEV-ONLY: the same secret signs the JWT and validates it on the
|
||||||
|
provider side; production deploys retrieve operator JWTs from the admin
|
||||||
|
API (AZ-690) instead. Mirrors `SatelliteProvider.TestSupport.JwtTokenFactory.Create`
|
||||||
|
on the .NET side so dev tokens behave identically to integration-test ones.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
python scripts/mint_dev_jwt.py
|
||||||
|
python scripts/mint_dev_jwt.py --lifetime-hours 12 --subject e2e-runner
|
||||||
|
python scripts/mint_dev_jwt.py --permission GPS # unlocks /api/satellite/upload
|
||||||
|
export SATELLITE_PROVIDER_API_KEY="$(python scripts/mint_dev_jwt.py)"
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
import jwt
|
||||||
|
except ImportError as exc:
|
||||||
|
sys.stderr.write(
|
||||||
|
"ERROR: pyjwt not installed. Run `pip install pyjwt>=2.8,<3.0`\n"
|
||||||
|
"(or `pip install -e .[dev]` from the repo root) and retry.\n"
|
||||||
|
f"Underlying ImportError: {exc}\n"
|
||||||
|
)
|
||||||
|
sys.exit(72)
|
||||||
|
|
||||||
|
|
||||||
|
_MIN_SECRET_BYTES = 32
|
||||||
|
|
||||||
|
|
||||||
|
def _load_env_file(path: Path) -> dict[str, str]:
|
||||||
|
"""Parse a minimal KEY=VALUE env file. Honours quoting; ignores comments."""
|
||||||
|
|
||||||
|
if not path.is_file():
|
||||||
|
return {}
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
for raw in path.read_text("utf-8").splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
if "=" not in line:
|
||||||
|
continue
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
key = key.strip()
|
||||||
|
value = value.strip().strip('"').strip("'")
|
||||||
|
if key:
|
||||||
|
out[key] = value
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve(name: str, env_file_values: dict[str, str]) -> str | None:
|
||||||
|
value = os.environ.get(name)
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
return env_file_values.get(name)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"--lifetime-hours",
|
||||||
|
type=float,
|
||||||
|
default=8.0,
|
||||||
|
help="Token lifetime in hours (default: 8).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--subject",
|
||||||
|
default="gps-denied-onboard-e2e",
|
||||||
|
help="`sub` claim (default: gps-denied-onboard-e2e).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--env-file",
|
||||||
|
default=".env.test",
|
||||||
|
help="Fallback env file (default: .env.test in CWD).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--permission",
|
||||||
|
action="append",
|
||||||
|
default=None,
|
||||||
|
metavar="NAME",
|
||||||
|
help=(
|
||||||
|
"Add a value to the `permissions` JWT claim. Repeatable "
|
||||||
|
"(e.g. --permission GPS --permission FL). Use `GPS` to unlock "
|
||||||
|
"/api/satellite/upload on the satellite-provider."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
env_file_values = _load_env_file(Path(args.env_file))
|
||||||
|
secret = _resolve("JWT_SECRET", env_file_values)
|
||||||
|
issuer = _resolve("JWT_ISSUER", env_file_values)
|
||||||
|
audience = _resolve("JWT_AUDIENCE", env_file_values)
|
||||||
|
|
||||||
|
missing = [
|
||||||
|
name
|
||||||
|
for name, value in [
|
||||||
|
("JWT_SECRET", secret),
|
||||||
|
("JWT_ISSUER", issuer),
|
||||||
|
("JWT_AUDIENCE", audience),
|
||||||
|
]
|
||||||
|
if not value
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
sys.stderr.write(
|
||||||
|
"ERROR: required env var(s) not set: "
|
||||||
|
+ ", ".join(missing)
|
||||||
|
+ f"\n(looked at environment + {args.env_file})\n"
|
||||||
|
)
|
||||||
|
return 73
|
||||||
|
|
||||||
|
assert secret is not None
|
||||||
|
if len(secret.encode("utf-8")) < _MIN_SECRET_BYTES:
|
||||||
|
sys.stderr.write(
|
||||||
|
f"ERROR: JWT_SECRET is {len(secret)} bytes; HMAC-SHA256 requires "
|
||||||
|
f">= {_MIN_SECRET_BYTES} bytes per the provider's contract.\n"
|
||||||
|
)
|
||||||
|
return 74
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
payload = {
|
||||||
|
"sub": args.subject,
|
||||||
|
"iss": issuer,
|
||||||
|
"aud": audience,
|
||||||
|
"jti": uuid.uuid4().hex,
|
||||||
|
"iat": int(now.timestamp()),
|
||||||
|
"nbf": int(now.timestamp()),
|
||||||
|
"exp": int((now + timedelta(hours=args.lifetime_hours)).timestamp()),
|
||||||
|
}
|
||||||
|
if args.permission:
|
||||||
|
payload["permissions"] = list(args.permission)
|
||||||
|
|
||||||
|
token = jwt.encode(payload, secret, algorithm="HS256")
|
||||||
|
sys.stdout.write(token + "\n")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -81,6 +81,21 @@ if [ "${#JWT_SECRET}" -lt 32 ]; then
|
|||||||
exit 70
|
exit 70
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# AZ-777 Phase 1: the e2e-runner needs a Bearer token to call the real
|
||||||
|
# satellite-provider. If the caller didn't pre-export SATELLITE_PROVIDER_API_KEY
|
||||||
|
# (preferred for CI / repeatable runs), mint a fresh dev JWT here using the
|
||||||
|
# same JWT_SECRET / JWT_ISSUER / JWT_AUDIENCE the producer validates against.
|
||||||
|
if [ -z "${SATELLITE_PROVIDER_API_KEY:-}" ]; then
|
||||||
|
echo "[run-tests-jetson] minting fresh dev JWT via scripts/mint_dev_jwt.py"
|
||||||
|
if ! SATELLITE_PROVIDER_API_KEY=$(python3 "${SCRIPT_DIR}/mint_dev_jwt.py" \
|
||||||
|
--subject e2e-runner-jetson 2>&1); then
|
||||||
|
echo "ERROR: mint_dev_jwt.py failed:" >&2
|
||||||
|
echo "${SATELLITE_PROVIDER_API_KEY}" >&2
|
||||||
|
exit 71
|
||||||
|
fi
|
||||||
|
export SATELLITE_PROVIDER_API_KEY
|
||||||
|
fi
|
||||||
|
|
||||||
# Pre-quote the env vars for safe heredoc injection. `${var@Q}` would be
|
# Pre-quote the env vars for safe heredoc injection. `${var@Q}` would be
|
||||||
# cleaner but it requires bash 4.4+; macOS ships bash 3.2 and we want to
|
# cleaner but it requires bash 4.4+; macOS ships bash 3.2 and we want to
|
||||||
# stay portable. `printf %q` is in bash 2+.
|
# stay portable. `printf %q` is in bash 2+.
|
||||||
@@ -88,6 +103,7 @@ JWT_SECRET_Q=$(printf '%q' "${JWT_SECRET}")
|
|||||||
JWT_ISSUER_Q=$(printf '%q' "${JWT_ISSUER}")
|
JWT_ISSUER_Q=$(printf '%q' "${JWT_ISSUER}")
|
||||||
JWT_AUDIENCE_Q=$(printf '%q' "${JWT_AUDIENCE}")
|
JWT_AUDIENCE_Q=$(printf '%q' "${JWT_AUDIENCE}")
|
||||||
GOOGLE_MAPS_API_KEY_Q=$(printf '%q' "${GOOGLE_MAPS_API_KEY:-}")
|
GOOGLE_MAPS_API_KEY_Q=$(printf '%q' "${GOOGLE_MAPS_API_KEY:-}")
|
||||||
|
SATELLITE_PROVIDER_API_KEY_Q=$(printf '%q' "${SATELLITE_PROVIDER_API_KEY}")
|
||||||
|
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
# Pre-flight
|
# Pre-flight
|
||||||
@@ -208,6 +224,7 @@ export JWT_SECRET=${JWT_SECRET_Q}
|
|||||||
export JWT_ISSUER=${JWT_ISSUER_Q}
|
export JWT_ISSUER=${JWT_ISSUER_Q}
|
||||||
export JWT_AUDIENCE=${JWT_AUDIENCE_Q}
|
export JWT_AUDIENCE=${JWT_AUDIENCE_Q}
|
||||||
export GOOGLE_MAPS_API_KEY=${GOOGLE_MAPS_API_KEY_Q}
|
export GOOGLE_MAPS_API_KEY=${GOOGLE_MAPS_API_KEY_Q}
|
||||||
|
export SATELLITE_PROVIDER_API_KEY=${SATELLITE_PROVIDER_API_KEY_Q}
|
||||||
cd "${REMOTE_DIR}"
|
cd "${REMOTE_DIR}"
|
||||||
docker compose -f "${COMPOSE_FILE}" build e2e-runner satellite-provider
|
docker compose -f "${COMPOSE_FILE}" build e2e-runner satellite-provider
|
||||||
EOF
|
EOF
|
||||||
@@ -226,6 +243,7 @@ export JWT_SECRET=${JWT_SECRET_Q}
|
|||||||
export JWT_ISSUER=${JWT_ISSUER_Q}
|
export JWT_ISSUER=${JWT_ISSUER_Q}
|
||||||
export JWT_AUDIENCE=${JWT_AUDIENCE_Q}
|
export JWT_AUDIENCE=${JWT_AUDIENCE_Q}
|
||||||
export GOOGLE_MAPS_API_KEY=${GOOGLE_MAPS_API_KEY_Q}
|
export GOOGLE_MAPS_API_KEY=${GOOGLE_MAPS_API_KEY_Q}
|
||||||
|
export SATELLITE_PROVIDER_API_KEY=${SATELLITE_PROVIDER_API_KEY_Q}
|
||||||
cd "${REMOTE_DIR}"
|
cd "${REMOTE_DIR}"
|
||||||
exec docker compose -f "${COMPOSE_FILE}" up \
|
exec docker compose -f "${COMPOSE_FILE}" up \
|
||||||
--abort-on-container-exit \
|
--abort-on-container-exit \
|
||||||
|
|||||||
@@ -29,7 +29,11 @@ from gps_denied_onboard.components.c11_tile_manager.errors import (
|
|||||||
CacheBudgetExceededError,
|
CacheBudgetExceededError,
|
||||||
RateLimitedError,
|
RateLimitedError,
|
||||||
ResolutionRejectionError,
|
ResolutionRejectionError,
|
||||||
|
RouteTerminalFailureError,
|
||||||
|
RouteTransientError,
|
||||||
|
RouteValidationError,
|
||||||
SatelliteProviderError,
|
SatelliteProviderError,
|
||||||
|
SatelliteProviderRouteError,
|
||||||
SessionNotActiveError,
|
SessionNotActiveError,
|
||||||
SignatureRejectedError,
|
SignatureRejectedError,
|
||||||
TileManagerError,
|
TileManagerError,
|
||||||
@@ -41,6 +45,10 @@ from gps_denied_onboard.components.c11_tile_manager.interface import (
|
|||||||
TileDownloader,
|
TileDownloader,
|
||||||
TileUploader,
|
TileUploader,
|
||||||
)
|
)
|
||||||
|
from gps_denied_onboard.components.c11_tile_manager.route_client import (
|
||||||
|
RouteSeedResult,
|
||||||
|
SatelliteProviderRouteClient,
|
||||||
|
)
|
||||||
from gps_denied_onboard.components.c11_tile_manager.signing_key import (
|
from gps_denied_onboard.components.c11_tile_manager.signing_key import (
|
||||||
PerFlightKeyManager,
|
PerFlightKeyManager,
|
||||||
)
|
)
|
||||||
@@ -74,7 +82,13 @@ __all__ = [
|
|||||||
"PublicKeyFingerprint",
|
"PublicKeyFingerprint",
|
||||||
"RateLimitedError",
|
"RateLimitedError",
|
||||||
"ResolutionRejectionError",
|
"ResolutionRejectionError",
|
||||||
|
"RouteSeedResult",
|
||||||
|
"RouteTerminalFailureError",
|
||||||
|
"RouteTransientError",
|
||||||
|
"RouteValidationError",
|
||||||
"SatelliteProviderError",
|
"SatelliteProviderError",
|
||||||
|
"SatelliteProviderRouteClient",
|
||||||
|
"SatelliteProviderRouteError",
|
||||||
"SectorClassification",
|
"SectorClassification",
|
||||||
"SessionNotActiveError",
|
"SessionNotActiveError",
|
||||||
"SignatureRejectedError",
|
"SignatureRejectedError",
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
"""C11 TileManager error family (AZ-316, AZ-318, AZ-319).
|
"""C11 TileManager error family (AZ-316, AZ-318, AZ-319, AZ-838).
|
||||||
|
|
||||||
Rooted at :class:`TileManagerError`. Both the upload (AZ-319) and
|
The C11 component carries TWO error hierarchies:
|
||||||
download (AZ-316) paths share the family parent so cross-path callers
|
|
||||||
can ``except TileManagerError`` to catch any C11-side terminal failure
|
1. **Tile path** — rooted at :class:`TileManagerError`. Both the upload
|
||||||
without enumerating subclasses.
|
(AZ-319) and download (AZ-316) paths share the family parent so
|
||||||
|
cross-path callers can ``except TileManagerError`` to catch any
|
||||||
|
C11-side per-tile terminal failure without enumerating subclasses.
|
||||||
|
2. **Route path** (AZ-838) — rooted at :class:`SatelliteProviderRouteError`.
|
||||||
|
The Route API is conceptually orthogonal to per-tile up/down: it
|
||||||
|
onboards a corridor of intermediate points server-side and
|
||||||
|
triggers background tile pre-fetch. Callers that want only the
|
||||||
|
route lifecycle stay clear of the tile-path family. Lives in the
|
||||||
|
same module because the HTTP transport, JWT auth, and TLS/insecure
|
||||||
|
plumbing are shared.
|
||||||
|
|
||||||
* :class:`SessionNotActiveError` (AZ-318) — :meth:`PerFlightKeyManager.sign`
|
* :class:`SessionNotActiveError` (AZ-318) — :meth:`PerFlightKeyManager.sign`
|
||||||
/ :meth:`record_signature_rejection` called outside an active session.
|
/ :meth:`record_signature_rejection` called outside an active session.
|
||||||
@@ -21,15 +30,32 @@ without enumerating subclasses.
|
|||||||
``resolution_m_per_px < 0.5``.
|
``resolution_m_per_px < 0.5``.
|
||||||
* :class:`CacheBudgetExceededError` (AZ-316) — surfaced when c6's
|
* :class:`CacheBudgetExceededError` (AZ-316) — surfaced when c6's
|
||||||
AZ-308 budget enforcer cannot reserve head-room for the download.
|
AZ-308 budget enforcer cannot reserve head-room for the download.
|
||||||
|
* :class:`SatelliteProviderRouteError` (AZ-838) — root of the Route API
|
||||||
|
family.
|
||||||
|
* :class:`RouteValidationError` (AZ-838) — pre-emptive validation OR
|
||||||
|
parent-suite 4xx + RFC 7807 ProblemDetails. Carries
|
||||||
|
``field_errors: dict[str, list[str]]`` populated from the wire body.
|
||||||
|
* :class:`RouteTransientError` (AZ-838) — 5xx / network / timeout. The
|
||||||
|
underlying ``httpx`` exception is preserved on ``__cause__``.
|
||||||
|
* :class:`RouteTerminalFailureError` (AZ-838) — ``mapsReady=True`` was
|
||||||
|
never reached: poll budget exhausted OR the server reported a
|
||||||
|
terminal failure status. ``detail`` carries the SP response JSON
|
||||||
|
(when available).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"CacheBudgetExceededError",
|
"CacheBudgetExceededError",
|
||||||
"RateLimitedError",
|
"RateLimitedError",
|
||||||
"ResolutionRejectionError",
|
"ResolutionRejectionError",
|
||||||
|
"RouteTerminalFailureError",
|
||||||
|
"RouteTransientError",
|
||||||
|
"RouteValidationError",
|
||||||
"SatelliteProviderError",
|
"SatelliteProviderError",
|
||||||
|
"SatelliteProviderRouteError",
|
||||||
"SessionNotActiveError",
|
"SessionNotActiveError",
|
||||||
"SignatureRejectedError",
|
"SignatureRejectedError",
|
||||||
"TileManagerError",
|
"TileManagerError",
|
||||||
@@ -106,3 +132,81 @@ class CacheBudgetExceededError(TileManagerError):
|
|||||||
catch a cache-full failure. The original c6 error is preserved
|
catch a cache-full failure. The original c6 error is preserved
|
||||||
on ``__cause__``.
|
on ``__cause__``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# AZ-838 — Route API error family (independent of TileManagerError)
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class SatelliteProviderRouteError(Exception):
|
||||||
|
"""Root of the AZ-838 Route API error family.
|
||||||
|
|
||||||
|
Independent of :class:`TileManagerError` because the Route API is
|
||||||
|
a server-side corridor onboarding flow, not a per-tile transfer.
|
||||||
|
Catching :class:`SatelliteProviderRouteError` matches every
|
||||||
|
Route-side terminal failure without enumerating subclasses; it
|
||||||
|
does NOT match per-tile failures from the AZ-316 / AZ-319 paths.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class RouteValidationError(SatelliteProviderRouteError):
|
||||||
|
"""Route request rejected as invalid.
|
||||||
|
|
||||||
|
Raised in two situations:
|
||||||
|
|
||||||
|
* **Pre-emptive validation** — the client mirrors AZ-809's
|
||||||
|
``CreateRouteRequestValidator`` so obviously-bad input fails
|
||||||
|
*before* the HTTP POST. ``field_errors`` carries the rule keys
|
||||||
|
the client checked (e.g. ``"points"``, ``"regionSizeMeters"``).
|
||||||
|
* **4xx response** — the parent-suite returned an
|
||||||
|
``application/problem+json`` body per
|
||||||
|
``error-shape.md`` v1.0.0. ``field_errors`` is parsed from the
|
||||||
|
response's ``errors`` map (RFC 7807 + extension).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
field_errors: dict[str, list[str]] | None = None,
|
||||||
|
http_status: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.field_errors: dict[str, list[str]] = dict(field_errors or {})
|
||||||
|
self.http_status: int | None = http_status
|
||||||
|
|
||||||
|
|
||||||
|
class RouteTransientError(SatelliteProviderRouteError):
|
||||||
|
"""Network / 5xx / timeout against the Route API.
|
||||||
|
|
||||||
|
The underlying ``httpx`` exception is preserved on ``__cause__``.
|
||||||
|
The caller (CLI / fixture / orchestrator) decides retry policy —
|
||||||
|
the client itself does NOT retry these so the caller can apply
|
||||||
|
its own budget without a hidden inner loop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class RouteTerminalFailureError(SatelliteProviderRouteError):
|
||||||
|
"""Polling concluded the route would never become map-ready.
|
||||||
|
|
||||||
|
Two trigger paths:
|
||||||
|
|
||||||
|
* The server transitioned the route to a terminal failure status
|
||||||
|
(``failed`` / ``error`` / ``rejected`` / etc.) — ``detail``
|
||||||
|
carries the SP response JSON for postmortem.
|
||||||
|
* ``poll_max_attempts`` rounds elapsed without the server
|
||||||
|
reporting ``mapsReady=true`` AND without a terminal failure
|
||||||
|
status — ``detail`` carries the LAST observed response JSON.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
detail: Any = None,
|
||||||
|
route_id: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.detail: Any = detail
|
||||||
|
self.route_id: str | None = route_id
|
||||||
|
|||||||
@@ -0,0 +1,914 @@
|
|||||||
|
"""C11 ``SatelliteProviderRouteClient`` (AZ-838 / Epic AZ-835 C2).
|
||||||
|
|
||||||
|
Operator-side HTTP client for the parent-suite Route API. Takes a
|
||||||
|
:class:`gps_denied_onboard.replay_input.tlog_route.RouteSpec` (produced
|
||||||
|
by AZ-836 / C1) and onboards it with ``satellite-provider``:
|
||||||
|
|
||||||
|
1. **Pre-emptive validation** mirrors the AZ-809
|
||||||
|
``CreateRouteRequestValidator`` rules so obviously-bad input fails
|
||||||
|
before the HTTP POST.
|
||||||
|
2. **POST** ``/api/satellite/route`` with ``requestMaps=true`` and
|
||||||
|
``createTilesZip=false``. Wire shape derived from the live DTOs in
|
||||||
|
``../satellite-provider/SatelliteProvider.Common/DTO/{CreateRouteRequest,RoutePoint,GeoPoint}.cs``.
|
||||||
|
3. **Poll** ``GET /api/satellite/route/{id}`` until ``mapsReady=true``
|
||||||
|
OR a terminal failure status; respects
|
||||||
|
:attr:`SatelliteProviderRouteClient.poll_max_attempts` and
|
||||||
|
:attr:`SatelliteProviderRouteClient.poll_interval_s`.
|
||||||
|
4. **Inventory verify** via ``POST /api/satellite/tiles/inventory`` —
|
||||||
|
enumerates the route's tile coverage locally from the
|
||||||
|
``RouteSpec`` waypoints + ``regionSizeMeters`` and counts the
|
||||||
|
``present=true`` entries returned by the server (lower bound on
|
||||||
|
the actual coverage, since the server interpolates intermediate
|
||||||
|
waypoints — documented in the contract).
|
||||||
|
5. **Return** :class:`RouteSeedResult` with provenance fields
|
||||||
|
(route id, terminal status, maps_ready flag, tile count, elapsed
|
||||||
|
time, sha256 of the submitted payload).
|
||||||
|
|
||||||
|
The error hierarchy is rooted at :class:`SatelliteProviderRouteError`
|
||||||
|
(in :mod:`.errors`), independent of :class:`TileManagerError` because
|
||||||
|
the Route API is a corridor-onboarding flow, not a per-tile transfer.
|
||||||
|
|
||||||
|
Lives under ``c11_tile_manager`` because the existing C11 plumbing
|
||||||
|
(JWT auth, TLS-insecure flag for self-signed dev certs) is shared and
|
||||||
|
because C11 is already gated ``BUILD_C11_TILE_MANAGER=ON`` for the
|
||||||
|
operator-orchestrator binary (and OFF for airborne) — same audience
|
||||||
|
as the Route API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from gps_denied_onboard.components.c11_tile_manager.errors import (
|
||||||
|
RouteTerminalFailureError,
|
||||||
|
RouteTransientError,
|
||||||
|
RouteValidationError,
|
||||||
|
)
|
||||||
|
from gps_denied_onboard.replay_input.tlog_route import RouteSpec
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"RouteSeedResult",
|
||||||
|
"SatelliteProviderRouteClient",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# AZ-838 wire constants — paths confirmed against
|
||||||
|
# `../satellite-provider/SatelliteProvider.Api/Program.cs:266`+ on
|
||||||
|
# 2026-05-22 (route create + route status) and against
|
||||||
|
# `tile_downloader.py::_INVENTORY_PATH` for the inventory verify step.
|
||||||
|
_ROUTE_CREATE_PATH = "/api/satellite/route"
|
||||||
|
_ROUTE_STATUS_PATH_TPL = "/api/satellite/route/{id}"
|
||||||
|
_INVENTORY_PATH = "/api/satellite/tiles/inventory"
|
||||||
|
_INVENTORY_MAX_ENTRIES_PER_REQUEST = 5000
|
||||||
|
|
||||||
|
# AZ-809 validator bounds (mirrored from
|
||||||
|
# `SatelliteProvider.Api/Validators/CreateRouteRequestValidator.cs`).
|
||||||
|
# Keep these in sync with that file — the client pre-emptively
|
||||||
|
# enforces them so obviously-bad input fails before the HTTP POST.
|
||||||
|
_VALIDATOR_NAME_MAX_LEN: int = 200
|
||||||
|
_VALIDATOR_DESCRIPTION_MAX_LEN: int = 1000
|
||||||
|
_VALIDATOR_REGION_SIZE_MIN_M: float = 100.0
|
||||||
|
_VALIDATOR_REGION_SIZE_MAX_M: float = 10_000.0
|
||||||
|
_VALIDATOR_ZOOM_MIN: int = 0
|
||||||
|
_VALIDATOR_ZOOM_MAX: int = 22
|
||||||
|
_VALIDATOR_POINTS_MIN: int = 2
|
||||||
|
_VALIDATOR_POINTS_MAX: int = 500
|
||||||
|
|
||||||
|
# Mirror of the parent-suite tile-size math used by C11
|
||||||
|
# (`tile_downloader._EARTH_EQUATORIAL_CIRCUMFERENCE_M` /
|
||||||
|
# `_TILE_SIZE_PIXELS`). Re-stated here so the inventory-coverage
|
||||||
|
# enumeration does not depend on a private constant from the
|
||||||
|
# downloader module.
|
||||||
|
_EARTH_EQUATORIAL_CIRCUMFERENCE_M: float = 40_075_016.686
|
||||||
|
|
||||||
|
# Terminal status strings the parent suite reports via
|
||||||
|
# `GET /api/satellite/route/{id}`. Mirrors `seed_region.py`'s set so
|
||||||
|
# both Region and Route flows agree on terminal semantics.
|
||||||
|
_TERMINAL_STATUSES: frozenset[str] = frozenset(
|
||||||
|
{"completed", "failed", "error", "done", "succeeded", "rejected"}
|
||||||
|
)
|
||||||
|
_FAILURE_STATUSES: frozenset[str] = frozenset(
|
||||||
|
{"failed", "error", "rejected"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Default poll cadence — picked to match `seed_region.py` so the two
|
||||||
|
# CLIs feel identical to operators.
|
||||||
|
_DEFAULT_POLL_INTERVAL_S: float = 5.0
|
||||||
|
_DEFAULT_POLL_MAX_ATTEMPTS: int = 60
|
||||||
|
_DEFAULT_REQUEST_TIMEOUT_S: float = 30.0
|
||||||
|
|
||||||
|
_COMPONENT = "c11_tile_manager.route_client"
|
||||||
|
_LOG_KIND_SUBMIT = "c11.route.submit"
|
||||||
|
_LOG_KIND_POLL_TICK = "c11.route.poll.tick"
|
||||||
|
_LOG_KIND_POLL_TERMINAL = "c11.route.poll.terminal"
|
||||||
|
_LOG_KIND_INVENTORY = "c11.route.inventory"
|
||||||
|
_LOG_KIND_VALIDATION_FAIL = "c11.route.validation_failed"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RouteSeedResult:
|
||||||
|
"""Outcome of one :meth:`SatelliteProviderRouteClient.seed_route` call.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
route_id: The ``id`` field POSTed in the request — kept here
|
||||||
|
so the caller can re-query ``GET /api/satellite/route/{id}``
|
||||||
|
without re-deriving it.
|
||||||
|
terminal_status: The server's last observed status string
|
||||||
|
(one of the values in :data:`_TERMINAL_STATUSES`, lower-
|
||||||
|
cased). On a healthy run this is typically ``completed``.
|
||||||
|
maps_ready: ``True`` if the server reported ``mapsReady=true``
|
||||||
|
within the poll budget. ``False`` only on terminal
|
||||||
|
failure paths that do NOT raise (currently impossible —
|
||||||
|
terminal failures always raise; the field is here for
|
||||||
|
forward compatibility if the server adds a "ready
|
||||||
|
without maps" state).
|
||||||
|
tile_count: Number of (z, x, y) entries the inventory call
|
||||||
|
reported as ``present=true``. Lower bound on the actual
|
||||||
|
tile coverage produced by the server, since the local
|
||||||
|
enumeration does NOT account for the server-side
|
||||||
|
~200 m intermediate-point interpolation documented in
|
||||||
|
``../satellite-provider/_docs/02_document/contracts/api/route-creation.md``.
|
||||||
|
elapsed_ms: Wall-clock milliseconds from the start of the
|
||||||
|
POST submission to the completion of the inventory verify.
|
||||||
|
submitted_payload_sha256: SHA-256 hex digest of the JSON body
|
||||||
|
POSTed to ``/api/satellite/route`` (provenance / audit).
|
||||||
|
"""
|
||||||
|
|
||||||
|
route_id: uuid.UUID
|
||||||
|
terminal_status: str
|
||||||
|
maps_ready: bool
|
||||||
|
tile_count: int
|
||||||
|
elapsed_ms: int
|
||||||
|
submitted_payload_sha256: str
|
||||||
|
|
||||||
|
|
||||||
|
class SatelliteProviderRouteClient:
|
||||||
|
"""HTTP client for the parent-suite Route API (AZ-838).
|
||||||
|
|
||||||
|
Constructor parameters mirror the operator-side ergonomics
|
||||||
|
(``base_url`` + ``jwt`` + ``tls_insecure`` for self-signed dev
|
||||||
|
certs), matching the existing ``seed_region.py`` flag surface so
|
||||||
|
operators can use a single ``.env.test`` file.
|
||||||
|
|
||||||
|
For tests, an optional ``http_client`` may be injected — the
|
||||||
|
standard ``httpx.MockTransport`` pattern from
|
||||||
|
``test_tile_downloader.py`` works directly. When ``http_client``
|
||||||
|
is ``None`` (production / CLI use), the client owns its own
|
||||||
|
short-lived :class:`httpx.Client` per ``seed_route`` call so the
|
||||||
|
caller does not need to manage connection lifetime.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
jwt: str,
|
||||||
|
*,
|
||||||
|
tls_insecure: bool = False,
|
||||||
|
request_timeout_s: float = _DEFAULT_REQUEST_TIMEOUT_S,
|
||||||
|
poll_interval_s: float = _DEFAULT_POLL_INTERVAL_S,
|
||||||
|
poll_max_attempts: int = _DEFAULT_POLL_MAX_ATTEMPTS,
|
||||||
|
http_client: httpx.Client | None = None,
|
||||||
|
sleep: Any = None,
|
||||||
|
clock_ms: Any = None,
|
||||||
|
logger: logging.Logger | None = None,
|
||||||
|
) -> None:
|
||||||
|
if not base_url:
|
||||||
|
raise ValueError("base_url must be non-empty")
|
||||||
|
if not jwt:
|
||||||
|
raise ValueError("jwt must be non-empty")
|
||||||
|
if request_timeout_s <= 0:
|
||||||
|
raise ValueError(
|
||||||
|
f"request_timeout_s must be > 0; got {request_timeout_s}"
|
||||||
|
)
|
||||||
|
if poll_interval_s <= 0:
|
||||||
|
raise ValueError(
|
||||||
|
f"poll_interval_s must be > 0; got {poll_interval_s}"
|
||||||
|
)
|
||||||
|
if poll_max_attempts <= 0:
|
||||||
|
raise ValueError(
|
||||||
|
f"poll_max_attempts must be > 0; got {poll_max_attempts}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._base_url = base_url.rstrip("/")
|
||||||
|
self._jwt = jwt
|
||||||
|
self._tls_insecure = tls_insecure
|
||||||
|
self._request_timeout_s = float(request_timeout_s)
|
||||||
|
self._poll_interval_s = float(poll_interval_s)
|
||||||
|
self._poll_max_attempts = int(poll_max_attempts)
|
||||||
|
self._injected_client = http_client
|
||||||
|
self._sleep = sleep if sleep is not None else time.sleep
|
||||||
|
self._clock_ms = clock_ms if clock_ms is not None else _wall_clock_ms
|
||||||
|
self._logger = logger or logging.getLogger(
|
||||||
|
"gps_denied_onboard.components.c11_tile_manager.route_client"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def seed_route(
|
||||||
|
self,
|
||||||
|
spec: RouteSpec,
|
||||||
|
*,
|
||||||
|
name: str | None = None,
|
||||||
|
region_size_meters: float | None = None,
|
||||||
|
zoom_level: int = 18,
|
||||||
|
description: str | None = None,
|
||||||
|
) -> RouteSeedResult:
|
||||||
|
"""Onboard ``spec`` with the parent-suite Route API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
spec: The :class:`RouteSpec` produced by AZ-836's
|
||||||
|
``extract_route_from_tlog``.
|
||||||
|
name: Optional human-readable name. When ``None``, derived
|
||||||
|
from the spec's ``source_tlog`` stem + a short hash of
|
||||||
|
the waypoints (deterministic for the same RouteSpec).
|
||||||
|
region_size_meters: Per-waypoint coverage radius in
|
||||||
|
metres. When ``None``, falls back to
|
||||||
|
:attr:`RouteSpec.suggested_region_size_meters`. The
|
||||||
|
combined value MUST be in the AZ-809 validator range
|
||||||
|
``[100, 10000]``.
|
||||||
|
zoom_level: Web-Mercator zoom for the route. Defaults to
|
||||||
|
18 — matches ``seed_region.py``'s ``zoom_levels``
|
||||||
|
default. AZ-809 validator accepts ``[0, 22]``.
|
||||||
|
description: Optional free-text description (max 1000
|
||||||
|
chars per AZ-809).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`RouteSeedResult` on success.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RouteValidationError: Pre-emptive validation rejected the
|
||||||
|
inputs OR the server returned 4xx + RFC 7807.
|
||||||
|
RouteTransientError: 5xx / network / timeout. The
|
||||||
|
underlying ``httpx`` exception is on ``__cause__``.
|
||||||
|
RouteTerminalFailureError: ``mapsReady=true`` was never
|
||||||
|
reached within the poll budget OR the server reported
|
||||||
|
a terminal failure status.
|
||||||
|
"""
|
||||||
|
|
||||||
|
effective_region_size = float(
|
||||||
|
region_size_meters
|
||||||
|
if region_size_meters is not None
|
||||||
|
else spec.suggested_region_size_meters
|
||||||
|
)
|
||||||
|
effective_name = name if name is not None else _derive_name(spec)
|
||||||
|
route_id = uuid.uuid4()
|
||||||
|
|
||||||
|
request_body = self._build_request_body(
|
||||||
|
spec=spec,
|
||||||
|
route_id=route_id,
|
||||||
|
name=effective_name,
|
||||||
|
region_size_meters=effective_region_size,
|
||||||
|
zoom_level=zoom_level,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pre-emptive validation runs against the assembled body so
|
||||||
|
# the rules apply to whatever the server is about to see.
|
||||||
|
self._preemptive_validate(request_body)
|
||||||
|
|
||||||
|
payload_bytes = _canonical_json_bytes(request_body)
|
||||||
|
payload_sha256 = hashlib.sha256(payload_bytes).hexdigest()
|
||||||
|
|
||||||
|
if self._injected_client is not None:
|
||||||
|
return self._run(
|
||||||
|
client=self._injected_client,
|
||||||
|
route_id=route_id,
|
||||||
|
request_body=request_body,
|
||||||
|
payload_sha256=payload_sha256,
|
||||||
|
spec=spec,
|
||||||
|
region_size_meters=effective_region_size,
|
||||||
|
zoom_level=zoom_level,
|
||||||
|
)
|
||||||
|
|
||||||
|
with httpx.Client(verify=not self._tls_insecure) as client:
|
||||||
|
return self._run(
|
||||||
|
client=client,
|
||||||
|
route_id=route_id,
|
||||||
|
request_body=request_body,
|
||||||
|
payload_sha256=payload_sha256,
|
||||||
|
spec=spec,
|
||||||
|
region_size_meters=effective_region_size,
|
||||||
|
zoom_level=zoom_level,
|
||||||
|
)
|
||||||
|
|
||||||
|
def build_planned_payload(
|
||||||
|
self,
|
||||||
|
spec: RouteSpec,
|
||||||
|
*,
|
||||||
|
name: str | None = None,
|
||||||
|
region_size_meters: float | None = None,
|
||||||
|
zoom_level: int = 18,
|
||||||
|
description: str | None = None,
|
||||||
|
) -> tuple[dict[str, Any], str]:
|
||||||
|
"""Return the planned request body + its sha256 without HTTP.
|
||||||
|
|
||||||
|
Powers ``seed_route.py --dry-run`` (AC-7). Runs the same
|
||||||
|
pre-emptive validation as :meth:`seed_route`, so a dry-run
|
||||||
|
surfaces validation errors the same way a live run would.
|
||||||
|
"""
|
||||||
|
|
||||||
|
effective_region_size = float(
|
||||||
|
region_size_meters
|
||||||
|
if region_size_meters is not None
|
||||||
|
else spec.suggested_region_size_meters
|
||||||
|
)
|
||||||
|
effective_name = name if name is not None else _derive_name(spec)
|
||||||
|
route_id = uuid.uuid4()
|
||||||
|
body = self._build_request_body(
|
||||||
|
spec=spec,
|
||||||
|
route_id=route_id,
|
||||||
|
name=effective_name,
|
||||||
|
region_size_meters=effective_region_size,
|
||||||
|
zoom_level=zoom_level,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
self._preemptive_validate(body)
|
||||||
|
sha256 = hashlib.sha256(_canonical_json_bytes(body)).hexdigest()
|
||||||
|
return body, sha256
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Internal pipeline
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _run(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
client: httpx.Client,
|
||||||
|
route_id: uuid.UUID,
|
||||||
|
request_body: dict[str, Any],
|
||||||
|
payload_sha256: str,
|
||||||
|
spec: RouteSpec,
|
||||||
|
region_size_meters: float,
|
||||||
|
zoom_level: int,
|
||||||
|
) -> RouteSeedResult:
|
||||||
|
started_ms = self._clock_ms()
|
||||||
|
self._submit_route(client, route_id, request_body, payload_sha256)
|
||||||
|
terminal_status, maps_ready, last_payload = self._poll_until_terminal(
|
||||||
|
client, route_id
|
||||||
|
)
|
||||||
|
if terminal_status in _FAILURE_STATUSES:
|
||||||
|
raise RouteTerminalFailureError(
|
||||||
|
f"satellite-provider reported terminal failure status "
|
||||||
|
f"{terminal_status!r} for route {route_id}",
|
||||||
|
detail=last_payload,
|
||||||
|
route_id=str(route_id),
|
||||||
|
)
|
||||||
|
if not maps_ready:
|
||||||
|
raise RouteTerminalFailureError(
|
||||||
|
f"route {route_id} did not reach mapsReady=true within "
|
||||||
|
f"{self._poll_max_attempts} polls "
|
||||||
|
f"(interval {self._poll_interval_s}s); last status="
|
||||||
|
f"{terminal_status!r}",
|
||||||
|
detail=last_payload,
|
||||||
|
route_id=str(route_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
tile_count = self._verify_inventory(
|
||||||
|
client=client,
|
||||||
|
spec=spec,
|
||||||
|
zoom_level=zoom_level,
|
||||||
|
region_size_meters=region_size_meters,
|
||||||
|
)
|
||||||
|
elapsed_ms = max(0, self._clock_ms() - started_ms)
|
||||||
|
return RouteSeedResult(
|
||||||
|
route_id=route_id,
|
||||||
|
terminal_status=terminal_status,
|
||||||
|
maps_ready=maps_ready,
|
||||||
|
tile_count=tile_count,
|
||||||
|
elapsed_ms=elapsed_ms,
|
||||||
|
submitted_payload_sha256=payload_sha256,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _submit_route(
|
||||||
|
self,
|
||||||
|
client: httpx.Client,
|
||||||
|
route_id: uuid.UUID,
|
||||||
|
request_body: dict[str, Any],
|
||||||
|
payload_sha256: str,
|
||||||
|
) -> None:
|
||||||
|
url = self._base_url + _ROUTE_CREATE_PATH
|
||||||
|
try:
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
headers=self._auth_headers(),
|
||||||
|
json=request_body,
|
||||||
|
timeout=self._request_timeout_s,
|
||||||
|
)
|
||||||
|
except (httpx.HTTPError,) as exc:
|
||||||
|
raise RouteTransientError(
|
||||||
|
f"satellite-provider unreachable for POST {url}: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
self._logger.info(
|
||||||
|
"Route submission attempted",
|
||||||
|
extra={
|
||||||
|
"component": _COMPONENT,
|
||||||
|
"kind": _LOG_KIND_SUBMIT,
|
||||||
|
"kv": {
|
||||||
|
"route_id": str(route_id),
|
||||||
|
"http_status": response.status_code,
|
||||||
|
"payload_sha256_first16": payload_sha256[:16],
|
||||||
|
"n_points": len(request_body["points"]),
|
||||||
|
"zoom_level": request_body["zoomLevel"],
|
||||||
|
"region_size_meters": request_body["regionSizeMeters"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
return
|
||||||
|
if 400 <= response.status_code < 500:
|
||||||
|
field_errors = _parse_problem_details(response)
|
||||||
|
raise RouteValidationError(
|
||||||
|
f"satellite-provider rejected route POST with HTTP "
|
||||||
|
f"{response.status_code}",
|
||||||
|
field_errors=field_errors,
|
||||||
|
http_status=response.status_code,
|
||||||
|
)
|
||||||
|
# 5xx and any other unexpected status are transient.
|
||||||
|
raise RouteTransientError(
|
||||||
|
f"satellite-provider returned HTTP {response.status_code} "
|
||||||
|
f"for POST {url}; body={response.text[:200]!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _poll_until_terminal(
|
||||||
|
self,
|
||||||
|
client: httpx.Client,
|
||||||
|
route_id: uuid.UUID,
|
||||||
|
) -> tuple[str, bool, dict[str, Any] | None]:
|
||||||
|
url = self._base_url + _ROUTE_STATUS_PATH_TPL.format(id=route_id)
|
||||||
|
last_status: str = "unknown"
|
||||||
|
last_payload: dict[str, Any] | None = None
|
||||||
|
last_maps_ready: bool = False
|
||||||
|
|
||||||
|
for attempt in range(1, self._poll_max_attempts + 1):
|
||||||
|
try:
|
||||||
|
response = client.get(
|
||||||
|
url,
|
||||||
|
headers=self._auth_headers(),
|
||||||
|
timeout=self._request_timeout_s,
|
||||||
|
)
|
||||||
|
except (httpx.HTTPError,) as exc:
|
||||||
|
# Surfaced as transient — caller decides retry policy.
|
||||||
|
raise RouteTransientError(
|
||||||
|
f"satellite-provider unreachable polling route "
|
||||||
|
f"{route_id}: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if 400 <= response.status_code < 500:
|
||||||
|
field_errors = _parse_problem_details(response)
|
||||||
|
raise RouteValidationError(
|
||||||
|
f"satellite-provider rejected route status query "
|
||||||
|
f"with HTTP {response.status_code}",
|
||||||
|
field_errors=field_errors,
|
||||||
|
http_status=response.status_code,
|
||||||
|
)
|
||||||
|
if response.status_code >= 500:
|
||||||
|
raise RouteTransientError(
|
||||||
|
f"satellite-provider returned HTTP "
|
||||||
|
f"{response.status_code} polling route {route_id}; "
|
||||||
|
f"body={response.text[:200]!r}"
|
||||||
|
)
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise RouteTransientError(
|
||||||
|
f"satellite-provider returned unexpected HTTP "
|
||||||
|
f"{response.status_code} polling route {route_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RouteTransientError(
|
||||||
|
f"satellite-provider returned non-JSON body polling "
|
||||||
|
f"route {route_id}: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
last_payload = payload if isinstance(payload, dict) else None
|
||||||
|
last_maps_ready = bool(_safe_get(payload, "mapsReady", default=False))
|
||||||
|
status_raw = _safe_get(payload, "status", default="unknown")
|
||||||
|
last_status = str(status_raw).lower() if status_raw is not None else "unknown"
|
||||||
|
|
||||||
|
self._logger.info(
|
||||||
|
"Route poll tick",
|
||||||
|
extra={
|
||||||
|
"component": _COMPONENT,
|
||||||
|
"kind": _LOG_KIND_POLL_TICK,
|
||||||
|
"kv": {
|
||||||
|
"route_id": str(route_id),
|
||||||
|
"attempt": attempt,
|
||||||
|
"max_attempts": self._poll_max_attempts,
|
||||||
|
"status": last_status,
|
||||||
|
"maps_ready": last_maps_ready,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if last_maps_ready or last_status in _TERMINAL_STATUSES:
|
||||||
|
self._logger.info(
|
||||||
|
"Route poll terminal",
|
||||||
|
extra={
|
||||||
|
"component": _COMPONENT,
|
||||||
|
"kind": _LOG_KIND_POLL_TERMINAL,
|
||||||
|
"kv": {
|
||||||
|
"route_id": str(route_id),
|
||||||
|
"attempt": attempt,
|
||||||
|
"status": last_status,
|
||||||
|
"maps_ready": last_maps_ready,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return last_status, last_maps_ready, last_payload
|
||||||
|
|
||||||
|
if attempt < self._poll_max_attempts:
|
||||||
|
self._sleep(self._poll_interval_s)
|
||||||
|
|
||||||
|
return last_status, last_maps_ready, last_payload
|
||||||
|
|
||||||
|
def _verify_inventory(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
client: httpx.Client,
|
||||||
|
spec: RouteSpec,
|
||||||
|
zoom_level: int,
|
||||||
|
region_size_meters: float,
|
||||||
|
) -> int:
|
||||||
|
coords = _enumerate_route_tile_coords(
|
||||||
|
waypoints=spec.waypoints,
|
||||||
|
region_size_meters=region_size_meters,
|
||||||
|
zoom_level=zoom_level,
|
||||||
|
)
|
||||||
|
if not coords:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
url = self._base_url + _INVENTORY_PATH
|
||||||
|
present_count = 0
|
||||||
|
for batch_start in range(0, len(coords), _INVENTORY_MAX_ENTRIES_PER_REQUEST):
|
||||||
|
batch = coords[batch_start : batch_start + _INVENTORY_MAX_ENTRIES_PER_REQUEST]
|
||||||
|
body = {"tiles": [{"z": z, "x": x, "y": y} for (z, x, y) in batch]}
|
||||||
|
try:
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
headers=self._auth_headers(),
|
||||||
|
json=body,
|
||||||
|
timeout=self._request_timeout_s,
|
||||||
|
)
|
||||||
|
except (httpx.HTTPError,) as exc:
|
||||||
|
raise RouteTransientError(
|
||||||
|
f"satellite-provider unreachable for inventory verify: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if 400 <= response.status_code < 500:
|
||||||
|
field_errors = _parse_problem_details(response)
|
||||||
|
raise RouteValidationError(
|
||||||
|
f"satellite-provider rejected inventory verify with "
|
||||||
|
f"HTTP {response.status_code}",
|
||||||
|
field_errors=field_errors,
|
||||||
|
http_status=response.status_code,
|
||||||
|
)
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise RouteTransientError(
|
||||||
|
f"satellite-provider returned HTTP "
|
||||||
|
f"{response.status_code} for inventory verify"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RouteTransientError(
|
||||||
|
f"satellite-provider returned non-JSON body for "
|
||||||
|
f"inventory verify: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
results = payload.get("results") if isinstance(payload, dict) else None
|
||||||
|
if not isinstance(results, list):
|
||||||
|
raise RouteTransientError(
|
||||||
|
"satellite-provider inventory response missing "
|
||||||
|
"'results' array"
|
||||||
|
)
|
||||||
|
present_count += sum(1 for entry in results if entry.get("present"))
|
||||||
|
|
||||||
|
self._logger.info(
|
||||||
|
"Route inventory verify complete",
|
||||||
|
extra={
|
||||||
|
"component": _COMPONENT,
|
||||||
|
"kind": _LOG_KIND_INVENTORY,
|
||||||
|
"kv": {
|
||||||
|
"tiles_queried": len(coords),
|
||||||
|
"tiles_present": present_count,
|
||||||
|
"zoom_level": zoom_level,
|
||||||
|
"region_size_meters": region_size_meters,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return present_count
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Validation + payload assembly
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _build_request_body(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
spec: RouteSpec,
|
||||||
|
route_id: uuid.UUID,
|
||||||
|
name: str,
|
||||||
|
region_size_meters: float,
|
||||||
|
zoom_level: int,
|
||||||
|
description: str | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Assemble the wire body matching CreateRouteRequest.cs / RoutePoint.cs.
|
||||||
|
|
||||||
|
Per the AZ-809 batch-03 review F3, ``RoutePoint`` uses
|
||||||
|
``[JsonPropertyName("lat"|"lon")]`` so we serialize ``lat`` /
|
||||||
|
``lon`` (NOT ``latitude`` / ``longitude``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
body: dict[str, Any] = {
|
||||||
|
"id": str(route_id),
|
||||||
|
"name": name,
|
||||||
|
"regionSizeMeters": float(region_size_meters),
|
||||||
|
"zoomLevel": int(zoom_level),
|
||||||
|
"points": [
|
||||||
|
{"lat": float(lat), "lon": float(lon)}
|
||||||
|
for (lat, lon) in spec.waypoints
|
||||||
|
],
|
||||||
|
"requestMaps": True,
|
||||||
|
"createTilesZip": False,
|
||||||
|
}
|
||||||
|
if description is not None:
|
||||||
|
body["description"] = description
|
||||||
|
return body
|
||||||
|
|
||||||
|
def _preemptive_validate(self, body: dict[str, Any]) -> None:
|
||||||
|
errors: dict[str, list[str]] = {}
|
||||||
|
|
||||||
|
# id — non-zero Guid (AZ-809 Rule 1).
|
||||||
|
try:
|
||||||
|
parsed_id = uuid.UUID(str(body["id"]))
|
||||||
|
if parsed_id.int == 0:
|
||||||
|
errors.setdefault("id", []).append(
|
||||||
|
"id must be a non-zero Guid"
|
||||||
|
)
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
errors.setdefault("id", []).append("id must be a valid Guid")
|
||||||
|
|
||||||
|
# name — required, length [1, 200].
|
||||||
|
name_value = body.get("name", "")
|
||||||
|
if not isinstance(name_value, str) or not name_value:
|
||||||
|
errors.setdefault("name", []).append("name must be non-empty")
|
||||||
|
elif len(name_value) > _VALIDATOR_NAME_MAX_LEN:
|
||||||
|
errors.setdefault("name", []).append(
|
||||||
|
f"name length must be <= {_VALIDATOR_NAME_MAX_LEN}; "
|
||||||
|
f"got {len(name_value)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# description — optional, length <= 1000.
|
||||||
|
if "description" in body:
|
||||||
|
desc_value = body["description"]
|
||||||
|
if desc_value is not None:
|
||||||
|
if not isinstance(desc_value, str):
|
||||||
|
errors.setdefault("description", []).append(
|
||||||
|
"description must be a string"
|
||||||
|
)
|
||||||
|
elif len(desc_value) > _VALIDATOR_DESCRIPTION_MAX_LEN:
|
||||||
|
errors.setdefault("description", []).append(
|
||||||
|
f"description length must be <= "
|
||||||
|
f"{_VALIDATOR_DESCRIPTION_MAX_LEN}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# regionSizeMeters — [100, 10000].
|
||||||
|
region = body.get("regionSizeMeters")
|
||||||
|
if not isinstance(region, (int, float)):
|
||||||
|
errors.setdefault("regionSizeMeters", []).append(
|
||||||
|
"regionSizeMeters must be numeric"
|
||||||
|
)
|
||||||
|
elif not (
|
||||||
|
_VALIDATOR_REGION_SIZE_MIN_M
|
||||||
|
<= float(region)
|
||||||
|
<= _VALIDATOR_REGION_SIZE_MAX_M
|
||||||
|
):
|
||||||
|
errors.setdefault("regionSizeMeters", []).append(
|
||||||
|
f"regionSizeMeters must be in "
|
||||||
|
f"[{_VALIDATOR_REGION_SIZE_MIN_M}, "
|
||||||
|
f"{_VALIDATOR_REGION_SIZE_MAX_M}]; got {region}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# zoomLevel — [0, 22].
|
||||||
|
zoom = body.get("zoomLevel")
|
||||||
|
if not isinstance(zoom, int):
|
||||||
|
errors.setdefault("zoomLevel", []).append(
|
||||||
|
"zoomLevel must be an integer"
|
||||||
|
)
|
||||||
|
elif not _VALIDATOR_ZOOM_MIN <= zoom <= _VALIDATOR_ZOOM_MAX:
|
||||||
|
errors.setdefault("zoomLevel", []).append(
|
||||||
|
f"zoomLevel must be in "
|
||||||
|
f"[{_VALIDATOR_ZOOM_MIN}, {_VALIDATOR_ZOOM_MAX}]; "
|
||||||
|
f"got {zoom}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# points — count [2, 500] + per-point lat/lon range.
|
||||||
|
points = body.get("points")
|
||||||
|
if not isinstance(points, list):
|
||||||
|
errors.setdefault("points", []).append("points must be a list")
|
||||||
|
else:
|
||||||
|
if len(points) < _VALIDATOR_POINTS_MIN:
|
||||||
|
errors.setdefault("points", []).append(
|
||||||
|
f"points count must be >= {_VALIDATOR_POINTS_MIN}; "
|
||||||
|
f"got {len(points)}"
|
||||||
|
)
|
||||||
|
elif len(points) > _VALIDATOR_POINTS_MAX:
|
||||||
|
errors.setdefault("points", []).append(
|
||||||
|
f"points count must be <= {_VALIDATOR_POINTS_MAX}; "
|
||||||
|
f"got {len(points)}"
|
||||||
|
)
|
||||||
|
for idx, point in enumerate(points):
|
||||||
|
key = f"points[{idx}]"
|
||||||
|
if not isinstance(point, dict):
|
||||||
|
errors.setdefault(key, []).append("must be an object")
|
||||||
|
continue
|
||||||
|
lat = point.get("lat")
|
||||||
|
lon = point.get("lon")
|
||||||
|
if not isinstance(lat, (int, float)) or not -90.0 <= float(lat) <= 90.0:
|
||||||
|
errors.setdefault(key, []).append(
|
||||||
|
f"lat must be in [-90, 90]; got {lat!r}"
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
not isinstance(lon, (int, float))
|
||||||
|
or not -180.0 <= float(lon) <= 180.0
|
||||||
|
):
|
||||||
|
errors.setdefault(key, []).append(
|
||||||
|
f"lon must be in [-180, 180]; got {lon!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# createTilesZip ⇒ requestMaps cross-field rule.
|
||||||
|
if body.get("createTilesZip") and not body.get("requestMaps"):
|
||||||
|
errors.setdefault("createTilesZip", []).append(
|
||||||
|
"createTilesZip=true requires requestMaps=true"
|
||||||
|
)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
self._logger.warning(
|
||||||
|
"Route pre-emptive validation failed",
|
||||||
|
extra={
|
||||||
|
"component": _COMPONENT,
|
||||||
|
"kind": _LOG_KIND_VALIDATION_FAIL,
|
||||||
|
"kv": {"field_errors": errors},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
raise RouteValidationError(
|
||||||
|
"Route request failed pre-emptive validation against "
|
||||||
|
"AZ-809 rules; see field_errors",
|
||||||
|
field_errors=errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _auth_headers(self) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {self._jwt}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# Module-level helpers
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _wall_clock_ms() -> int:
|
||||||
|
return int(time.monotonic() * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_json_bytes(body: dict[str, Any]) -> bytes:
|
||||||
|
"""Stable byte representation for the sha256 audit field.
|
||||||
|
|
||||||
|
``sort_keys=True`` + tight separators give the same digest for
|
||||||
|
semantically-equal payloads that differ only in dict ordering.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return json.dumps(body, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_name(spec: RouteSpec) -> str:
|
||||||
|
"""Default ``name`` = ``<tlog-stem>-<short-hash>`` (deterministic)."""
|
||||||
|
|
||||||
|
stem = spec.source_tlog.stem if spec.source_tlog else "tlog"
|
||||||
|
digest = hashlib.sha256(
|
||||||
|
repr(spec.waypoints).encode("utf-8")
|
||||||
|
).hexdigest()[:8]
|
||||||
|
return f"{stem}-{digest}"
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_get(payload: Any, key: str, default: Any = None) -> Any:
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
return payload.get(key, default)
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_problem_details(response: httpx.Response) -> dict[str, list[str]]:
|
||||||
|
"""Extract RFC 7807 ``errors`` map from a ``ProblemDetails`` body.
|
||||||
|
|
||||||
|
Tolerates non-JSON bodies and shapes that lack the ``errors`` key
|
||||||
|
(returns an empty dict). Caller surfaces the dict through
|
||||||
|
:attr:`RouteValidationError.field_errors`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
decoded = response.json()
|
||||||
|
except ValueError:
|
||||||
|
return {}
|
||||||
|
if not isinstance(decoded, dict):
|
||||||
|
return {}
|
||||||
|
raw_errors = decoded.get("errors")
|
||||||
|
if not isinstance(raw_errors, dict):
|
||||||
|
return {}
|
||||||
|
out: dict[str, list[str]] = {}
|
||||||
|
for k, v in raw_errors.items():
|
||||||
|
if isinstance(v, list):
|
||||||
|
out[str(k)] = [str(item) for item in v]
|
||||||
|
elif isinstance(v, str):
|
||||||
|
out[str(k)] = [v]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _enumerate_route_tile_coords(
|
||||||
|
*,
|
||||||
|
waypoints: tuple[tuple[float, float], ...],
|
||||||
|
region_size_meters: float,
|
||||||
|
zoom_level: int,
|
||||||
|
) -> list[tuple[int, int, int]]:
|
||||||
|
"""Compute the union of (z, x, y) tiles covering each waypoint's box.
|
||||||
|
|
||||||
|
The local enumeration boxes a ``region_size_meters x
|
||||||
|
region_size_meters`` square around each waypoint at the requested
|
||||||
|
zoom and unions the resulting tile coords. This UNDER-counts the
|
||||||
|
actual server-side coverage because the server interpolates
|
||||||
|
intermediate points (~200 m spacing per AZ-809 docs); the
|
||||||
|
inventory verify step therefore reports a lower bound on the
|
||||||
|
server's tile count, which is exactly what the
|
||||||
|
:attr:`RouteSeedResult.tile_count` field promises in its
|
||||||
|
docstring.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not waypoints or region_size_meters <= 0:
|
||||||
|
return []
|
||||||
|
seen: set[tuple[int, int, int]] = set()
|
||||||
|
half = region_size_meters / 2.0
|
||||||
|
for lat, lon in waypoints:
|
||||||
|
# Convert metres to degrees at the waypoint's latitude.
|
||||||
|
# Latitude: 1 deg ≈ 111_320 m (constant within a few percent).
|
||||||
|
# Longitude: 1 deg ≈ 111_320 * cos(lat) m.
|
||||||
|
lat_delta_deg = half / 111_320.0
|
||||||
|
cos_lat = math.cos(math.radians(lat))
|
||||||
|
if cos_lat <= 1e-9:
|
||||||
|
cos_lat = 1e-9
|
||||||
|
lon_delta_deg = half / (111_320.0 * cos_lat)
|
||||||
|
|
||||||
|
bbox_min_lat = lat - lat_delta_deg
|
||||||
|
bbox_max_lat = lat + lat_delta_deg
|
||||||
|
bbox_min_lon = lon - lon_delta_deg
|
||||||
|
bbox_max_lon = lon + lon_delta_deg
|
||||||
|
x_min, y_max = _latlon_to_tile_xy(zoom_level, bbox_min_lat, bbox_min_lon)
|
||||||
|
x_max, y_min = _latlon_to_tile_xy(zoom_level, bbox_max_lat, bbox_max_lon)
|
||||||
|
x_lo, x_hi = (x_min, x_max) if x_min <= x_max else (x_max, x_min)
|
||||||
|
y_lo, y_hi = (y_min, y_max) if y_min <= y_max else (y_max, y_min)
|
||||||
|
for x in range(x_lo, x_hi + 1):
|
||||||
|
for y in range(y_lo, y_hi + 1):
|
||||||
|
seen.add((zoom_level, x, y))
|
||||||
|
return sorted(seen)
|
||||||
|
|
||||||
|
|
||||||
|
def _latlon_to_tile_xy(zoom: int, lat_deg: float, lon_deg: float) -> tuple[int, int]:
|
||||||
|
"""Standard slippy-map projection (matches ``_expected_tile_coords``).
|
||||||
|
|
||||||
|
Mirrors ``WgsConverter.latlon_to_tile_xy`` math without the import
|
||||||
|
so the route client can run without requiring the WGS converter
|
||||||
|
to be initialised. Clamps lat to the Web-Mercator pole limit
|
||||||
|
(±85.05113 deg) — the parent suite uses the same clamp.
|
||||||
|
"""
|
||||||
|
|
||||||
|
lat_clamped = max(-85.05112878, min(85.05112878, lat_deg))
|
||||||
|
n = 1 << int(zoom)
|
||||||
|
x = int((lon_deg + 180.0) / 360.0 * n)
|
||||||
|
lat_rad = math.radians(lat_clamped)
|
||||||
|
y = int(
|
||||||
|
(1.0 - math.log(math.tan(lat_rad) + 1.0 / math.cos(lat_rad)) / math.pi)
|
||||||
|
/ 2.0
|
||||||
|
* n
|
||||||
|
)
|
||||||
|
x = max(0, min(n - 1, x))
|
||||||
|
y = max(0, min(n - 1, y))
|
||||||
|
return x, y
|
||||||
@@ -1,11 +1,23 @@
|
|||||||
"""C11 ``HttpTileDownloader`` (AZ-316) — concrete :class:`TileDownloader`.
|
"""C11 ``HttpTileDownloader`` (AZ-316, AZ-777) — concrete :class:`TileDownloader`.
|
||||||
|
|
||||||
Operator-side pre-flight download path. Authenticated GETs against
|
Operator-side pre-flight download path. Authenticated POST inventory
|
||||||
``satellite-provider``, RESTRICT-SAT-4 enforcement at the C11 boundary,
|
lookups + slippy-map GETs against ``satellite-provider``, RESTRICT-SAT-4
|
||||||
c6 writes via the AZ-303 store + metadata Protocols (which run AZ-307's
|
enforcement at the C11 boundary, c6 writes via the AZ-303 store +
|
||||||
freshness gate at insert), AZ-308 cache-headroom pre-check before any
|
metadata Protocols (which run AZ-307's freshness gate at insert),
|
||||||
GET fires, and a per-``(flight_id, request_hash)`` journal for
|
AZ-308 cache-headroom pre-check before any GET fires (using a
|
||||||
idempotent re-runs.
|
configured per-tile bytes estimate, since the inventory contract does
|
||||||
|
not return content-length hints), and a per-``(flight_id,
|
||||||
|
request_hash)`` journal for idempotent re-runs.
|
||||||
|
|
||||||
|
Contract surface (AZ-777, against the parent-suite
|
||||||
|
``satellite-provider`` v1.0.0 inventory contract — see
|
||||||
|
``../satellite-provider/_docs/02_document/contracts/api/tile-inventory.md``):
|
||||||
|
|
||||||
|
* ``POST /api/satellite/tiles/inventory`` — bulk lookup of (z,x,y) tile
|
||||||
|
coords; returns one entry per request item with ``present: true|false``
|
||||||
|
and (when present) the metadata C11 needs to drive the resolution gate
|
||||||
|
and the c6 write.
|
||||||
|
* ``GET /tiles/{z}/{x}/{y}`` — slippy-map tile fetch by coords.
|
||||||
|
|
||||||
Architecture
|
Architecture
|
||||||
------------
|
------------
|
||||||
@@ -26,6 +38,7 @@ from __future__ import annotations
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
@@ -50,6 +63,7 @@ from gps_denied_onboard.components.c11_tile_manager.errors import (
|
|||||||
RateLimitedError,
|
RateLimitedError,
|
||||||
SatelliteProviderError,
|
SatelliteProviderError,
|
||||||
)
|
)
|
||||||
|
from gps_denied_onboard.helpers.wgs_converter import WgsConverter
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DOWNLOAD_JOURNAL_DIRNAME",
|
"DOWNLOAD_JOURNAL_DIRNAME",
|
||||||
@@ -58,9 +72,24 @@ __all__ = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
_LIST_PATH = "/api/satellite/tiles"
|
# AZ-777: parent-suite contract v1.0.0 (see module docstring).
|
||||||
_GET_PATH = "/api/satellite/tiles"
|
_INVENTORY_PATH = "/api/satellite/tiles/inventory"
|
||||||
_LIST_QUERY_LIST_ONLY = "list-only"
|
_TILES_PATH = "/tiles"
|
||||||
|
_INVENTORY_MAX_ENTRIES_PER_REQUEST = 5000
|
||||||
|
# Inventory response does not carry a content-length hint, but the
|
||||||
|
# AZ-308 budget pre-check needs a per-tile estimate before any GET
|
||||||
|
# fires. 50 KiB is conservative for 256x256 JPEG basemap tiles
|
||||||
|
# (typical CARTO Voyager tiles run 8-30 KiB; UAV-captured uploads
|
||||||
|
# run 30-80 KiB). Sized to over-reserve rather than under-reserve.
|
||||||
|
_DEFAULT_ESTIMATED_TILE_BYTES = 50_000
|
||||||
|
# Web-Mercator at zoom 0 covers the full equatorial circumference of
|
||||||
|
# the WGS-84 ellipsoid (≈40 075 016.686 m). Tile ground size at any
|
||||||
|
# (zoom, lat) follows: circumference * cos(lat_rad) / 2^zoom (the
|
||||||
|
# cos(lat) factor is the same projection-stretch correction the
|
||||||
|
# parent-suite uses to compute ``resolutionMPerPx``).
|
||||||
|
_EARTH_EQUATORIAL_CIRCUMFERENCE_M = 40_075_016.686
|
||||||
|
_TILE_SIZE_PIXELS = 256
|
||||||
|
|
||||||
DOWNLOAD_JOURNAL_DIRNAME = ".c11/journal"
|
DOWNLOAD_JOURNAL_DIRNAME = ".c11/journal"
|
||||||
_LOCKFILE_PATH = ".c11/lock"
|
_LOCKFILE_PATH = ".c11/lock"
|
||||||
_DEFAULT_BACKOFF_SCHEDULE_S: tuple[float, ...] = (1.0, 2.0, 4.0, 8.0)
|
_DEFAULT_BACKOFF_SCHEDULE_S: tuple[float, ...] = (1.0, 2.0, 4.0, 8.0)
|
||||||
@@ -116,9 +145,7 @@ class _TileWriterLike(Protocol):
|
|||||||
sector_class: str,
|
sector_class: str,
|
||||||
) -> str: ...
|
) -> str: ...
|
||||||
|
|
||||||
def tile_already_present(
|
def tile_already_present(self, *, zoom_level: int, lat: float, lon: float) -> bool: ...
|
||||||
self, *, zoom_level: int, lat: float, lon: float
|
|
||||||
) -> bool: ...
|
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
@@ -295,6 +322,79 @@ def _default_sleep(seconds: float) -> None:
|
|||||||
clock.sleep_until_ns(clock.monotonic_ns() + int(seconds * 1_000_000_000))
|
clock.sleep_until_ns(clock.monotonic_ns() + int(seconds * 1_000_000_000))
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# AZ-777 slippy-map helpers
|
||||||
|
#
|
||||||
|
# The parent-suite inventory contract (v1.0.0) is keyed by explicit
|
||||||
|
# (z, x, y) slippy-map coords. C11 enumerates the grid from the bbox
|
||||||
|
# locally and converts inventory hits back into lat/lon for c6 writes.
|
||||||
|
# Math matches the parent suite's Web-Mercator projection so the
|
||||||
|
# resolution / tile-size hints round-trip identically.
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _enumerate_bbox_tile_coords(
|
||||||
|
bbox_min_lat: float,
|
||||||
|
bbox_min_lon: float,
|
||||||
|
bbox_max_lat: float,
|
||||||
|
bbox_max_lon: float,
|
||||||
|
zoom_levels: tuple[int, ...],
|
||||||
|
) -> tuple[tuple[int, int, int], ...]:
|
||||||
|
"""Return every (z, x, y) whose tile bounds intersect the bbox.
|
||||||
|
|
||||||
|
Slippy-map y grows southward, so the SW corner has (low x, high y)
|
||||||
|
and the NE corner has (high x, low y). The enumeration is inclusive
|
||||||
|
on both ends.
|
||||||
|
"""
|
||||||
|
|
||||||
|
coords: list[tuple[int, int, int]] = []
|
||||||
|
for zoom in zoom_levels:
|
||||||
|
x_sw, y_sw = WgsConverter.latlon_to_tile_xy(int(zoom), bbox_min_lat, bbox_min_lon)
|
||||||
|
x_ne, y_ne = WgsConverter.latlon_to_tile_xy(int(zoom), bbox_max_lat, bbox_max_lon)
|
||||||
|
x_lo, x_hi = (x_sw, x_ne) if x_sw <= x_ne else (x_ne, x_sw)
|
||||||
|
y_lo, y_hi = (y_ne, y_sw) if y_ne <= y_sw else (y_sw, y_ne)
|
||||||
|
for x in range(x_lo, x_hi + 1):
|
||||||
|
for y in range(y_lo, y_hi + 1):
|
||||||
|
coords.append((int(zoom), x, y))
|
||||||
|
return tuple(coords)
|
||||||
|
|
||||||
|
|
||||||
|
def _tile_center_latlon(zoom: int, x: int, y: int) -> tuple[float, float]:
|
||||||
|
bounds = WgsConverter.tile_xy_to_latlon_bounds(int(zoom), int(x), int(y))
|
||||||
|
lat = (bounds.min_lat_deg + bounds.max_lat_deg) / 2.0
|
||||||
|
lon = (bounds.min_lon_deg + bounds.max_lon_deg) / 2.0
|
||||||
|
return lat, lon
|
||||||
|
|
||||||
|
|
||||||
|
def _tile_size_meters_at(zoom: int, lat_deg: float) -> float:
|
||||||
|
return _EARTH_EQUATORIAL_CIRCUMFERENCE_M * math.cos(math.radians(lat_deg)) / (1 << int(zoom))
|
||||||
|
|
||||||
|
|
||||||
|
def _format_tile_id_str(zoom: int, x: int, y: int) -> str:
|
||||||
|
return f"{int(zoom)}_{int(x)}_{int(y)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_tile_id_str(tile_id_str: str) -> tuple[int, int, int]:
|
||||||
|
parts = tile_id_str.split("_")
|
||||||
|
if len(parts) != 3:
|
||||||
|
raise ValueError(f"tile_id_str must be 'z_x_y'; got {tile_id_str!r}")
|
||||||
|
try:
|
||||||
|
return int(parts[0]), int(parts[1]), int(parts[2])
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"tile_id_str must contain three integers separated by '_'; got {tile_id_str!r}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _chunk_iter(
|
||||||
|
seq: tuple[tuple[int, int, int], ...],
|
||||||
|
chunk_size: int,
|
||||||
|
) -> list[tuple[tuple[int, int, int], ...]]:
|
||||||
|
if chunk_size <= 0:
|
||||||
|
raise ValueError(f"chunk_size must be > 0; got {chunk_size}")
|
||||||
|
return [tuple(seq[start : start + chunk_size]) for start in range(0, len(seq), chunk_size)]
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
# Internal session-state container
|
# Internal session-state container
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
@@ -389,8 +489,12 @@ class HttpTileDownloader:
|
|||||||
counts = existing.tile_counts
|
counts = existing.tile_counts
|
||||||
return DownloadBatchReport(
|
return DownloadBatchReport(
|
||||||
outcome=DownloadOutcome.IDEMPOTENT_NO_OP,
|
outcome=DownloadOutcome.IDEMPOTENT_NO_OP,
|
||||||
tiles_requested=int(counts.get("tiles_requested", len(existing.tile_ids_completed))),
|
tiles_requested=int(
|
||||||
tiles_downloaded=int(counts.get("tiles_downloaded", len(existing.tile_ids_completed))),
|
counts.get("tiles_requested", len(existing.tile_ids_completed))
|
||||||
|
),
|
||||||
|
tiles_downloaded=int(
|
||||||
|
counts.get("tiles_downloaded", len(existing.tile_ids_completed))
|
||||||
|
),
|
||||||
tiles_rejected_resolution=int(counts.get("tiles_rejected_resolution", 0)),
|
tiles_rejected_resolution=int(counts.get("tiles_rejected_resolution", 0)),
|
||||||
tiles_rejected_freshness=int(counts.get("tiles_rejected_freshness", 0)),
|
tiles_rejected_freshness=int(counts.get("tiles_rejected_freshness", 0)),
|
||||||
tiles_downgraded=int(counts.get("tiles_downgraded", 0)),
|
tiles_downgraded=int(counts.get("tiles_downgraded", 0)),
|
||||||
@@ -546,58 +650,103 @@ class HttpTileDownloader:
|
|||||||
bbox_max_lon: float,
|
bbox_max_lon: float,
|
||||||
zoom_levels: tuple[int, ...],
|
zoom_levels: tuple[int, ...],
|
||||||
) -> list[TileSummary]:
|
) -> list[TileSummary]:
|
||||||
params = {
|
"""POST ``/api/satellite/tiles/inventory`` for every (z,x,y) in bbox.
|
||||||
"bbox": f"{bbox_min_lat},{bbox_min_lon},{bbox_max_lat},{bbox_max_lon}",
|
|
||||||
"zoom": ",".join(str(z) for z in zoom_levels),
|
AZ-777: the satellite-provider v1.0.0 inventory contract is
|
||||||
_LIST_QUERY_LIST_ONLY: "true",
|
keyed by explicit slippy-map coords, NOT by a server-side
|
||||||
}
|
bbox query. This method enumerates the tile grid for the
|
||||||
response = self._send_get(
|
bbox x zoom set, chunks into ≤5000-entry POSTs (the
|
||||||
self._config.satellite_provider_url.rstrip("/") + _LIST_PATH,
|
``TileInventoryLimits.MaxEntriesPerRequest`` cap), and
|
||||||
params=params,
|
returns one :class:`TileSummary` per ``present=true`` entry.
|
||||||
|
Absent tiles are silently dropped — they need to be seeded
|
||||||
|
via ``POST /api/satellite/request`` upstream before they
|
||||||
|
become downloadable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
tile_coords = _enumerate_bbox_tile_coords(
|
||||||
|
bbox_min_lat,
|
||||||
|
bbox_min_lon,
|
||||||
|
bbox_max_lat,
|
||||||
|
bbox_max_lon,
|
||||||
|
zoom_levels,
|
||||||
|
)
|
||||||
|
if not tile_coords:
|
||||||
|
return []
|
||||||
|
|
||||||
|
summaries: list[TileSummary] = []
|
||||||
|
for chunk in _chunk_iter(tile_coords, _INVENTORY_MAX_ENTRIES_PER_REQUEST):
|
||||||
|
summaries.extend(self._fetch_inventory_chunk(chunk))
|
||||||
|
return summaries
|
||||||
|
|
||||||
|
def _fetch_inventory_chunk(self, chunk: tuple[tuple[int, int, int], ...]) -> list[TileSummary]:
|
||||||
|
body = {"tiles": [{"z": z, "x": x, "y": y} for (z, x, y) in chunk]}
|
||||||
|
response = self._send_post(
|
||||||
|
self._config.satellite_provider_url.rstrip("/") + _INVENTORY_PATH,
|
||||||
|
json_body=body,
|
||||||
session=None,
|
session=None,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
body = response.json()
|
decoded = response.json()
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
self._log_provider_failure(
|
self._log_provider_failure("inventory_not_json", response.status_code, str(exc))
|
||||||
"list_not_json", response.status_code, str(exc)
|
|
||||||
)
|
|
||||||
raise SatelliteProviderError(
|
raise SatelliteProviderError(
|
||||||
"satellite-provider returned non-JSON list-only body"
|
"satellite-provider returned non-JSON inventory body"
|
||||||
) from exc
|
) from exc
|
||||||
try:
|
try:
|
||||||
entries = body["tiles"]
|
entries = decoded["results"]
|
||||||
except (KeyError, TypeError) as exc:
|
except (KeyError, TypeError) as exc:
|
||||||
|
self._log_provider_failure("inventory_schema", response.status_code, str(exc))
|
||||||
|
raise SatelliteProviderError(
|
||||||
|
"satellite-provider inventory response missing 'results'"
|
||||||
|
) from exc
|
||||||
|
if len(entries) != len(chunk):
|
||||||
self._log_provider_failure(
|
self._log_provider_failure(
|
||||||
"list_schema", response.status_code, str(exc)
|
"inventory_order",
|
||||||
|
response.status_code,
|
||||||
|
f"results.len={len(entries)} request.tiles.len={len(chunk)}",
|
||||||
)
|
)
|
||||||
raise SatelliteProviderError(
|
raise SatelliteProviderError(
|
||||||
"satellite-provider list-only response missing 'tiles'"
|
f"satellite-provider inventory response broke order invariant: "
|
||||||
) from exc
|
f"len(results)={len(entries)} != len(request.tiles)={len(chunk)}"
|
||||||
|
)
|
||||||
|
|
||||||
summaries: list[TileSummary] = []
|
summaries: list[TileSummary] = []
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
try:
|
try:
|
||||||
summaries.append(
|
present = bool(entry["present"])
|
||||||
TileSummary(
|
except (KeyError, TypeError) as exc:
|
||||||
tile_id_str=str(entry["tile_id"]),
|
self._log_provider_failure("inventory_entry_schema", response.status_code, str(exc))
|
||||||
zoom_level=int(entry["zoom_level"]),
|
|
||||||
lat=float(entry["lat"]),
|
|
||||||
lon=float(entry["lon"]),
|
|
||||||
produced_at=_parse_iso(str(entry["produced_at"])),
|
|
||||||
resolution_m_per_px=float(entry["resolution_m_per_px"]),
|
|
||||||
estimated_bytes=int(entry["estimated_bytes"]),
|
|
||||||
tile_size_meters=float(entry.get("tile_size_meters", 100.0)),
|
|
||||||
tile_size_pixels=int(entry.get("tile_size_pixels", 256)),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except (KeyError, TypeError, ValueError) as exc:
|
|
||||||
self._log_provider_failure(
|
|
||||||
"list_tile_schema", response.status_code, str(exc)
|
|
||||||
)
|
|
||||||
raise SatelliteProviderError(
|
raise SatelliteProviderError(
|
||||||
"satellite-provider list-only entry missing required fields"
|
"satellite-provider inventory entry missing 'present'"
|
||||||
) from exc
|
) from exc
|
||||||
|
if not present:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
zoom = int(entry["z"])
|
||||||
|
x = int(entry["x"])
|
||||||
|
y = int(entry["y"])
|
||||||
|
produced_at = _parse_iso(str(entry["capturedAt"]))
|
||||||
|
resolution_m_per_px = float(entry["resolutionMPerPx"])
|
||||||
|
except (KeyError, TypeError, ValueError) as exc:
|
||||||
|
self._log_provider_failure("inventory_entry_schema", response.status_code, str(exc))
|
||||||
|
raise SatelliteProviderError(
|
||||||
|
"satellite-provider inventory present-entry missing "
|
||||||
|
"required fields (z/x/y/capturedAt/resolutionMPerPx)"
|
||||||
|
) from exc
|
||||||
|
lat, lon = _tile_center_latlon(zoom, x, y)
|
||||||
|
summaries.append(
|
||||||
|
TileSummary(
|
||||||
|
tile_id_str=_format_tile_id_str(zoom, x, y),
|
||||||
|
zoom_level=zoom,
|
||||||
|
lat=lat,
|
||||||
|
lon=lon,
|
||||||
|
produced_at=produced_at,
|
||||||
|
resolution_m_per_px=resolution_m_per_px,
|
||||||
|
estimated_bytes=_DEFAULT_ESTIMATED_TILE_BYTES,
|
||||||
|
tile_size_meters=_tile_size_meters_at(zoom, lat),
|
||||||
|
tile_size_pixels=_TILE_SIZE_PIXELS,
|
||||||
|
)
|
||||||
|
)
|
||||||
return summaries
|
return summaries
|
||||||
|
|
||||||
def _reserve_budget(
|
def _reserve_budget(
|
||||||
@@ -607,9 +756,7 @@ class HttpTileDownloader:
|
|||||||
session: _DownloadSession,
|
session: _DownloadSession,
|
||||||
) -> None:
|
) -> None:
|
||||||
remaining_bytes = sum(
|
remaining_bytes = sum(
|
||||||
int(s.estimated_bytes)
|
int(s.estimated_bytes) for s in summaries if s.tile_id_str not in completed_set
|
||||||
for s in summaries
|
|
||||||
if s.tile_id_str not in completed_set
|
|
||||||
)
|
)
|
||||||
if remaining_bytes <= 0:
|
if remaining_bytes <= 0:
|
||||||
return
|
return
|
||||||
@@ -621,8 +768,7 @@ class HttpTileDownloader:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._log_budget_failure(remaining_bytes, detail=str(exc))
|
self._log_budget_failure(remaining_bytes, detail=str(exc))
|
||||||
raise CacheBudgetExceededError(
|
raise CacheBudgetExceededError(
|
||||||
f"c6 budget enforcer refused {remaining_bytes} bytes "
|
f"c6 budget enforcer refused {remaining_bytes} bytes of head-room: {exc}"
|
||||||
f"of head-room: {exc}"
|
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
def _download_one_tile(
|
def _download_one_tile(
|
||||||
@@ -648,19 +794,21 @@ class HttpTileDownloader:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
zoom, x, y = _parse_tile_id_str(summary.tile_id_str)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise SatelliteProviderError(
|
||||||
|
f"internal: TileSummary.tile_id_str does not match the AZ-777 "
|
||||||
|
f"z_x_y format (got {summary.tile_id_str!r})"
|
||||||
|
) from exc
|
||||||
ingest_url = (
|
ingest_url = (
|
||||||
self._config.satellite_provider_url.rstrip("/")
|
self._config.satellite_provider_url.rstrip("/") + f"{_TILES_PATH}/{zoom}/{x}/{y}"
|
||||||
+ _GET_PATH
|
|
||||||
+ f"/{summary.tile_id_str}"
|
|
||||||
)
|
)
|
||||||
response = self._send_get(ingest_url, params=None, session=session)
|
response = self._send_get(ingest_url, params=None, session=session)
|
||||||
if not response.content:
|
if not response.content:
|
||||||
self._log_provider_failure(
|
self._log_provider_failure("empty_body", response.status_code, summary.tile_id_str)
|
||||||
"empty_body", response.status_code, summary.tile_id_str
|
|
||||||
)
|
|
||||||
raise SatelliteProviderError(
|
raise SatelliteProviderError(
|
||||||
f"satellite-provider returned empty body for tile_id="
|
f"satellite-provider returned empty body for tile_id={summary.tile_id_str}"
|
||||||
f"{summary.tile_id_str}"
|
|
||||||
)
|
)
|
||||||
tile_blob = response.content
|
tile_blob = response.content
|
||||||
content_sha256_hex = hashlib.sha256(tile_blob).hexdigest()
|
content_sha256_hex = hashlib.sha256(tile_blob).hexdigest()
|
||||||
@@ -717,15 +865,40 @@ class HttpTileDownloader:
|
|||||||
) -> httpx.Response:
|
) -> httpx.Response:
|
||||||
"""GET with auth header + 429 / 5xx handling."""
|
"""GET with auth header + 429 / 5xx handling."""
|
||||||
|
|
||||||
|
return self._send_request("GET", url, params=params, json_body=None, session=session)
|
||||||
|
|
||||||
|
def _send_post(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
json_body: Any,
|
||||||
|
session: _DownloadSession | None,
|
||||||
|
) -> httpx.Response:
|
||||||
|
"""POST with auth header + 429 / 5xx handling (AZ-777 inventory contract)."""
|
||||||
|
|
||||||
|
return self._send_request("POST", url, params=None, json_body=json_body, session=session)
|
||||||
|
|
||||||
|
def _send_request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
params: dict[str, str] | None,
|
||||||
|
json_body: Any,
|
||||||
|
session: _DownloadSession | None,
|
||||||
|
) -> httpx.Response:
|
||||||
|
"""Auth header + 429 / 5xx handling for GET and POST."""
|
||||||
|
|
||||||
headers = {"Authorization": f"Bearer {self._config.service_api_key}"}
|
headers = {"Authorization": f"Bearer {self._config.service_api_key}"}
|
||||||
attempt = 0
|
attempt = 0
|
||||||
last_error: str | None = None
|
last_error: str | None = None
|
||||||
while True:
|
while True:
|
||||||
attempt += 1
|
attempt += 1
|
||||||
try:
|
try:
|
||||||
response = self._http_client.get(
|
response = self._http_client.request(
|
||||||
|
method,
|
||||||
url,
|
url,
|
||||||
params=params,
|
params=params,
|
||||||
|
json=json_body,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
timeout=self._config.download_http_timeout_s,
|
timeout=self._config.download_http_timeout_s,
|
||||||
)
|
)
|
||||||
@@ -740,18 +913,13 @@ class HttpTileDownloader:
|
|||||||
if attempt > self._config.download_max_5xx_retries:
|
if attempt > self._config.download_max_5xx_retries:
|
||||||
self._log_provider_failure("connection_error", None, last_error)
|
self._log_provider_failure("connection_error", None, last_error)
|
||||||
raise SatelliteProviderError(
|
raise SatelliteProviderError(
|
||||||
f"satellite-provider unreachable after "
|
f"satellite-provider unreachable after {attempt - 1} retries: {last_error}"
|
||||||
f"{attempt - 1} retries: {last_error}"
|
|
||||||
) from exc
|
) from exc
|
||||||
self._sleep_with_log(
|
self._sleep_with_log(self._backoff_for(attempt - 1), last_error, session)
|
||||||
self._backoff_for(attempt - 1), last_error, session
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if response.status_code in (401, 403):
|
if response.status_code in (401, 403):
|
||||||
self._log_provider_failure(
|
self._log_provider_failure("auth_failed", response.status_code, "fail-fast")
|
||||||
"auth_failed", response.status_code, "fail-fast"
|
|
||||||
)
|
|
||||||
raise SatelliteProviderError(
|
raise SatelliteProviderError(
|
||||||
f"satellite-provider rejected auth (http_status="
|
f"satellite-provider rejected auth (http_status="
|
||||||
f"{response.status_code}); fail-fast"
|
f"{response.status_code}); fail-fast"
|
||||||
@@ -767,12 +935,9 @@ class HttpTileDownloader:
|
|||||||
session.rate_limit_budget_used_s += wait_s
|
session.rate_limit_budget_used_s += wait_s
|
||||||
if wait_s <= 0 or (
|
if wait_s <= 0 or (
|
||||||
session is not None
|
session is not None
|
||||||
and session.rate_limit_budget_used_s
|
and session.rate_limit_budget_used_s >= self._config.download_max_retry_after_s
|
||||||
>= self._config.download_max_retry_after_s
|
|
||||||
):
|
):
|
||||||
self._log_provider_failure(
|
self._log_provider_failure("rate_limited", 429, "Retry-After budget exhausted")
|
||||||
"rate_limited", 429, "Retry-After budget exhausted"
|
|
||||||
)
|
|
||||||
raise RateLimitedError(
|
raise RateLimitedError(
|
||||||
"satellite-provider rate-limited the download; "
|
"satellite-provider rate-limited the download; "
|
||||||
f"cumulative Retry-After budget "
|
f"cumulative Retry-After budget "
|
||||||
@@ -785,22 +950,16 @@ class HttpTileDownloader:
|
|||||||
if response.status_code >= 500:
|
if response.status_code >= 500:
|
||||||
last_error = f"http_status={response.status_code}"
|
last_error = f"http_status={response.status_code}"
|
||||||
if attempt > self._config.download_max_5xx_retries:
|
if attempt > self._config.download_max_5xx_retries:
|
||||||
self._log_provider_failure(
|
self._log_provider_failure("persistent_5xx", response.status_code, last_error)
|
||||||
"persistent_5xx", response.status_code, last_error
|
|
||||||
)
|
|
||||||
raise SatelliteProviderError(
|
raise SatelliteProviderError(
|
||||||
f"satellite-provider returned {response.status_code} "
|
f"satellite-provider returned {response.status_code} "
|
||||||
f"after {attempt - 1} retries"
|
f"after {attempt - 1} retries"
|
||||||
)
|
)
|
||||||
self._sleep_with_log(
|
self._sleep_with_log(self._backoff_for(attempt - 1), last_error, session)
|
||||||
self._backoff_for(attempt - 1), last_error, session
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
self._log_provider_failure(
|
self._log_provider_failure("unexpected_status", response.status_code, "non-200")
|
||||||
"unexpected_status", response.status_code, "non-200"
|
|
||||||
)
|
|
||||||
raise SatelliteProviderError(
|
raise SatelliteProviderError(
|
||||||
f"satellite-provider returned unexpected status "
|
f"satellite-provider returned unexpected status "
|
||||||
f"{response.status_code} (expected 200)"
|
f"{response.status_code} (expected 200)"
|
||||||
@@ -814,9 +973,7 @@ class HttpTileDownloader:
|
|||||||
attempt_idx = len(self._backoff_schedule_s) - 1
|
attempt_idx = len(self._backoff_schedule_s) - 1
|
||||||
return self._backoff_schedule_s[attempt_idx]
|
return self._backoff_schedule_s[attempt_idx]
|
||||||
|
|
||||||
def _sleep_with_log(
|
def _sleep_with_log(self, wait_s: float, reason: str, session: _DownloadSession | None) -> None:
|
||||||
self, wait_s: float, reason: str, session: _DownloadSession | None
|
|
||||||
) -> None:
|
|
||||||
if session is not None:
|
if session is not None:
|
||||||
session.retry_count += 1
|
session.retry_count += 1
|
||||||
self._logger.warning(
|
self._logger.warning(
|
||||||
@@ -833,9 +990,7 @@ class HttpTileDownloader:
|
|||||||
)
|
)
|
||||||
self._sleep(wait_s)
|
self._sleep(wait_s)
|
||||||
|
|
||||||
def _log_provider_failure(
|
def _log_provider_failure(self, reason: str, http_status: int | None, detail: str) -> None:
|
||||||
self, reason: str, http_status: int | None, detail: str
|
|
||||||
) -> None:
|
|
||||||
self._logger.error(
|
self._logger.error(
|
||||||
"Download provider failed",
|
"Download provider failed",
|
||||||
extra={
|
extra={
|
||||||
@@ -850,9 +1005,7 @@ class HttpTileDownloader:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
def _log_budget_failure(
|
def _log_budget_failure(self, requested_bytes: int, detail: str | None = None) -> None:
|
||||||
self, requested_bytes: int, detail: str | None = None
|
|
||||||
) -> None:
|
|
||||||
self._logger.error(
|
self._logger.error(
|
||||||
"Cache-budget pre-check failed",
|
"Cache-budget pre-check failed",
|
||||||
extra={
|
extra={
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ from gps_denied_onboard.replay_input.tlog_ground_truth import (
|
|||||||
TlogGroundTruth,
|
TlogGroundTruth,
|
||||||
load_tlog_ground_truth,
|
load_tlog_ground_truth,
|
||||||
)
|
)
|
||||||
|
from gps_denied_onboard.replay_input.tlog_route import (
|
||||||
|
RouteExtractionError,
|
||||||
|
RouteSpec,
|
||||||
|
extract_route_from_tlog,
|
||||||
|
)
|
||||||
from gps_denied_onboard.replay_input.tlog_video_adapter import ReplayInputAdapter
|
from gps_denied_onboard.replay_input.tlog_video_adapter import ReplayInputAdapter
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -40,7 +45,10 @@ __all__ = [
|
|||||||
"ReplayInputAdapter",
|
"ReplayInputAdapter",
|
||||||
"ReplayInputAdapterError",
|
"ReplayInputAdapterError",
|
||||||
"ReplayInputBundle",
|
"ReplayInputBundle",
|
||||||
|
"RouteExtractionError",
|
||||||
|
"RouteSpec",
|
||||||
"TlogGpsFix",
|
"TlogGpsFix",
|
||||||
"TlogGroundTruth",
|
"TlogGroundTruth",
|
||||||
|
"extract_route_from_tlog",
|
||||||
"load_tlog_ground_truth",
|
"load_tlog_ground_truth",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,355 @@
|
|||||||
|
"""TlogRouteExtractor (AZ-836 / Epic AZ-835 C1).
|
||||||
|
|
||||||
|
Reduces an ArduPilot binary tlog to a :class:`RouteSpec` suitable for
|
||||||
|
posting to satellite-provider's ``POST /api/satellite/route`` endpoint
|
||||||
|
(consumed by AZ-838 C2). The pipeline is:
|
||||||
|
|
||||||
|
1. Load GPS fixes via :func:`load_tlog_ground_truth` (AZ-697) — no
|
||||||
|
MAVLink re-parsing here.
|
||||||
|
2. Trim leading + trailing rows where horizontal speed AND altitude
|
||||||
|
AGL are below the takeoff thresholds, isolating the active flight.
|
||||||
|
3. Coarsen the segment to <= ``max_waypoints`` via Douglas-Peucker on
|
||||||
|
the local-ENU projection produced by
|
||||||
|
:meth:`WgsConverter.latlonalt_to_local_enu` (AZ-279).
|
||||||
|
|
||||||
|
Public surface (re-exported from :mod:`gps_denied_onboard.replay_input`):
|
||||||
|
:class:`RouteSpec`, :class:`RouteExtractionError`,
|
||||||
|
:func:`extract_route_from_tlog`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from gps_denied_onboard._types.geo import LatLonAlt
|
||||||
|
from gps_denied_onboard.helpers.gps_compare import l2_horizontal_m
|
||||||
|
from gps_denied_onboard.helpers.wgs_converter import WgsConverter
|
||||||
|
from gps_denied_onboard.replay_input.errors import ReplayInputAdapterError
|
||||||
|
from gps_denied_onboard.replay_input.tlog_ground_truth import (
|
||||||
|
TlogGpsFix,
|
||||||
|
load_tlog_ground_truth,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"RouteExtractionError",
|
||||||
|
"RouteSpec",
|
||||||
|
"extract_route_from_tlog",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger("gps_denied_onboard.replay_input.tlog_route")
|
||||||
|
|
||||||
|
# Auto-tolerance binary-search bounds (AC-8).
|
||||||
|
_AUTO_TOLERANCE_MAX_ITERATIONS: int = 32
|
||||||
|
_AUTO_TOLERANCE_CONVERGENCE_M: float = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
class RouteExtractionError(ReplayInputAdapterError):
|
||||||
|
"""Raised when a tlog cannot be reduced to a :class:`RouteSpec`."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RouteSpec:
|
||||||
|
"""Coarsened flight route extracted from a tlog.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
waypoints: ``(lat_deg, lon_deg)`` pairs along the active
|
||||||
|
segment in chronological order. Length is between 1 and
|
||||||
|
the caller's ``max_waypoints``.
|
||||||
|
suggested_region_size_meters: Per-waypoint coverage radius
|
||||||
|
(meters) suggested for the satellite-provider region
|
||||||
|
request — currently the caller-supplied
|
||||||
|
``region_size_meters``.
|
||||||
|
source_tlog: Provenance — path to the tlog this route was
|
||||||
|
extracted from.
|
||||||
|
source_segment: ``(start_idx, end_idx)`` inclusive bounds into
|
||||||
|
the underlying tlog GPS row list. ``end_idx`` is the index
|
||||||
|
of the last row in the active segment.
|
||||||
|
total_distance_meters: Along-track great-circle distance of
|
||||||
|
the un-coarsened active segment in meters.
|
||||||
|
"""
|
||||||
|
|
||||||
|
waypoints: tuple[tuple[float, float], ...]
|
||||||
|
suggested_region_size_meters: float
|
||||||
|
source_tlog: Path
|
||||||
|
source_segment: tuple[int, int]
|
||||||
|
total_distance_meters: float
|
||||||
|
|
||||||
|
|
||||||
|
def extract_route_from_tlog(
|
||||||
|
tlog: Path,
|
||||||
|
*,
|
||||||
|
max_waypoints: int = 10,
|
||||||
|
min_takeoff_speed_m_s: float = 2.0,
|
||||||
|
min_takeoff_altitude_agl_m: float = 5.0,
|
||||||
|
douglas_peucker_tolerance_m: float | None = None,
|
||||||
|
region_size_meters: float = 500.0,
|
||||||
|
) -> RouteSpec:
|
||||||
|
"""Extract a coarsened :class:`RouteSpec` from a binary tlog.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tlog: Path to the ArduPilot binary tlog.
|
||||||
|
max_waypoints: Upper bound on the number of waypoints in the
|
||||||
|
returned route. Must be ``>= 1``.
|
||||||
|
min_takeoff_speed_m_s: Horizontal-speed threshold (m/s) below
|
||||||
|
which leading and trailing rows are trimmed.
|
||||||
|
min_takeoff_altitude_agl_m: Altitude AGL threshold (m) below
|
||||||
|
which leading and trailing rows are trimmed. AGL is
|
||||||
|
referenced to the minimum recorded altitude in the tlog
|
||||||
|
(the ArduPilot home position in practice).
|
||||||
|
douglas_peucker_tolerance_m: Explicit Douglas-Peucker tolerance
|
||||||
|
in meters. When ``None`` (default), a binary search picks
|
||||||
|
the smallest tolerance that satisfies ``max_waypoints``.
|
||||||
|
region_size_meters: Per-waypoint coverage radius (m) carried
|
||||||
|
on the returned :class:`RouteSpec`. Must be ``> 0``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A :class:`RouteSpec` describing the coarsened active segment.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: ``max_waypoints < 1`` or ``region_size_meters <= 0``.
|
||||||
|
RouteExtractionError: ``tlog`` is missing, contains no GPS
|
||||||
|
messages, or trims to fewer than 2 active fixes.
|
||||||
|
ReplayInputAdapterError: ``pymavlink`` is required but not
|
||||||
|
importable.
|
||||||
|
"""
|
||||||
|
if max_waypoints < 1:
|
||||||
|
raise ValueError(f"max_waypoints must be >= 1; got {max_waypoints}")
|
||||||
|
if region_size_meters <= 0:
|
||||||
|
raise ValueError(f"region_size_meters must be > 0; got {region_size_meters}")
|
||||||
|
|
||||||
|
if not tlog.is_file():
|
||||||
|
raise RouteExtractionError(f"tlog file not found: {tlog}")
|
||||||
|
|
||||||
|
ground_truth = load_tlog_ground_truth(tlog)
|
||||||
|
|
||||||
|
if not ground_truth.records:
|
||||||
|
raise RouteExtractionError(
|
||||||
|
f"tlog {tlog} contains no GLOBAL_POSITION_INT or GPS_RAW_INT messages"
|
||||||
|
)
|
||||||
|
|
||||||
|
start_idx, end_idx = _detect_active_segment(
|
||||||
|
ground_truth.records,
|
||||||
|
min_speed_m_s=min_takeoff_speed_m_s,
|
||||||
|
min_altitude_agl_m=min_takeoff_altitude_agl_m,
|
||||||
|
)
|
||||||
|
|
||||||
|
if end_idx - start_idx + 1 < 2:
|
||||||
|
raise RouteExtractionError(
|
||||||
|
f"tlog {tlog}: active segment too short after trim "
|
||||||
|
f"(min_takeoff_speed_m_s={min_takeoff_speed_m_s}, "
|
||||||
|
f"min_takeoff_altitude_agl_m={min_takeoff_altitude_agl_m}); "
|
||||||
|
f"got {end_idx - start_idx + 1} fix(es)"
|
||||||
|
)
|
||||||
|
|
||||||
|
active = ground_truth.records[start_idx : end_idx + 1]
|
||||||
|
total_distance_m = _along_track_distance(active)
|
||||||
|
waypoints = _coarsen_to_max_waypoints(
|
||||||
|
active,
|
||||||
|
max_waypoints=max_waypoints,
|
||||||
|
tolerance_m=douglas_peucker_tolerance_m,
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOGGER.debug(
|
||||||
|
"tlog_route: tlog=%s segment=[%d,%d] active=%d waypoints=%d distance_m=%.1f",
|
||||||
|
tlog,
|
||||||
|
start_idx,
|
||||||
|
end_idx,
|
||||||
|
len(active),
|
||||||
|
len(waypoints),
|
||||||
|
total_distance_m,
|
||||||
|
)
|
||||||
|
|
||||||
|
return RouteSpec(
|
||||||
|
waypoints=waypoints,
|
||||||
|
suggested_region_size_meters=region_size_meters,
|
||||||
|
source_tlog=tlog,
|
||||||
|
source_segment=(start_idx, end_idx),
|
||||||
|
total_distance_meters=total_distance_m,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_active_segment(
|
||||||
|
records: tuple[TlogGpsFix, ...],
|
||||||
|
*,
|
||||||
|
min_speed_m_s: float,
|
||||||
|
min_altitude_agl_m: float,
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
"""Find inclusive ``(start, end)`` bounds of the active flight.
|
||||||
|
|
||||||
|
AGL is referenced to the minimum altitude across all records (the
|
||||||
|
home position in ArduPilot tlogs). When no record satisfies the
|
||||||
|
thresholds, returns ``(0, -1)`` so the caller can raise with the
|
||||||
|
actual trim window in the error message.
|
||||||
|
"""
|
||||||
|
if not records:
|
||||||
|
return (0, -1)
|
||||||
|
|
||||||
|
reference_altitude_m = min(r.alt_m for r in records)
|
||||||
|
|
||||||
|
def _is_active(fix: TlogGpsFix) -> bool:
|
||||||
|
speed = math.hypot(fix.vx_m_s, fix.vy_m_s)
|
||||||
|
agl = fix.alt_m - reference_altitude_m
|
||||||
|
return speed >= min_speed_m_s and agl >= min_altitude_agl_m
|
||||||
|
|
||||||
|
start_idx = next(
|
||||||
|
(i for i, fix in enumerate(records) if _is_active(fix)),
|
||||||
|
-1,
|
||||||
|
)
|
||||||
|
if start_idx < 0:
|
||||||
|
return (0, -1)
|
||||||
|
|
||||||
|
end_idx = start_idx
|
||||||
|
for i in range(len(records) - 1, start_idx - 1, -1):
|
||||||
|
if _is_active(records[i]):
|
||||||
|
end_idx = i
|
||||||
|
break
|
||||||
|
|
||||||
|
return (start_idx, end_idx)
|
||||||
|
|
||||||
|
|
||||||
|
def _along_track_distance(records: tuple[TlogGpsFix, ...]) -> float:
|
||||||
|
"""Sum great-circle distances between successive fixes (m)."""
|
||||||
|
if len(records) < 2:
|
||||||
|
return 0.0
|
||||||
|
total = 0.0
|
||||||
|
for i in range(1, len(records)):
|
||||||
|
prev = records[i - 1]
|
||||||
|
curr = records[i]
|
||||||
|
total += l2_horizontal_m(prev.lat_deg, prev.lon_deg, curr.lat_deg, curr.lon_deg)
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def _coarsen_to_max_waypoints(
|
||||||
|
records: tuple[TlogGpsFix, ...],
|
||||||
|
*,
|
||||||
|
max_waypoints: int,
|
||||||
|
tolerance_m: float | None,
|
||||||
|
) -> tuple[tuple[float, float], ...]:
|
||||||
|
"""Coarsen ``records`` to ``(lat, lon)`` pairs.
|
||||||
|
|
||||||
|
In auto-tolerance mode (``tolerance_m is None``), short-circuits
|
||||||
|
when no coarsening is needed and otherwise binary-searches a
|
||||||
|
tolerance that keeps the result within ``max_waypoints``. With an
|
||||||
|
explicit tolerance, Douglas-Peucker is always applied at that
|
||||||
|
exact tolerance — the caller takes responsibility for the result
|
||||||
|
size, so ``max_waypoints`` is informational only in that mode.
|
||||||
|
"""
|
||||||
|
if tolerance_m is None:
|
||||||
|
if max_waypoints == 1:
|
||||||
|
return ((records[0].lat_deg, records[0].lon_deg),)
|
||||||
|
if len(records) <= max_waypoints:
|
||||||
|
return tuple((r.lat_deg, r.lon_deg) for r in records)
|
||||||
|
|
||||||
|
origin = LatLonAlt(
|
||||||
|
lat_deg=records[0].lat_deg,
|
||||||
|
lon_deg=records[0].lon_deg,
|
||||||
|
alt_m=records[0].alt_m,
|
||||||
|
)
|
||||||
|
projected = [_project_to_enu_xy(origin, fix) for fix in records]
|
||||||
|
|
||||||
|
if tolerance_m is not None:
|
||||||
|
kept = _douglas_peucker(projected, tolerance_m=tolerance_m)
|
||||||
|
else:
|
||||||
|
kept = _auto_tolerance_dp(projected, max_waypoints=max_waypoints)
|
||||||
|
|
||||||
|
return tuple((records[i].lat_deg, records[i].lon_deg) for i in kept)
|
||||||
|
|
||||||
|
|
||||||
|
def _project_to_enu_xy(origin: LatLonAlt, fix: TlogGpsFix) -> tuple[float, float]:
|
||||||
|
"""Project a fix onto the local-ENU east/north plane (m)."""
|
||||||
|
enu = WgsConverter.latlonalt_to_local_enu(
|
||||||
|
origin,
|
||||||
|
LatLonAlt(lat_deg=fix.lat_deg, lon_deg=fix.lon_deg, alt_m=fix.alt_m),
|
||||||
|
)
|
||||||
|
return (float(enu[0]), float(enu[1]))
|
||||||
|
|
||||||
|
|
||||||
|
def _douglas_peucker(
|
||||||
|
points: list[tuple[float, float]],
|
||||||
|
*,
|
||||||
|
tolerance_m: float,
|
||||||
|
) -> list[int]:
|
||||||
|
"""Return sorted indices kept by planar Douglas-Peucker.
|
||||||
|
|
||||||
|
Iterative stack-based implementation to avoid Python recursion
|
||||||
|
limits on long tracks.
|
||||||
|
"""
|
||||||
|
n = len(points)
|
||||||
|
if n < 2:
|
||||||
|
return list(range(n))
|
||||||
|
|
||||||
|
keep = [False] * n
|
||||||
|
keep[0] = True
|
||||||
|
keep[-1] = True
|
||||||
|
|
||||||
|
stack: list[tuple[int, int]] = [(0, n - 1)]
|
||||||
|
while stack:
|
||||||
|
lo, hi = stack.pop()
|
||||||
|
if hi - lo < 2:
|
||||||
|
continue
|
||||||
|
max_dist, max_idx = _max_perpendicular_distance(points, lo, hi)
|
||||||
|
if max_dist > tolerance_m:
|
||||||
|
keep[max_idx] = True
|
||||||
|
stack.append((lo, max_idx))
|
||||||
|
stack.append((max_idx, hi))
|
||||||
|
|
||||||
|
return [i for i, k in enumerate(keep) if k]
|
||||||
|
|
||||||
|
|
||||||
|
def _max_perpendicular_distance(
|
||||||
|
points: list[tuple[float, float]], lo: int, hi: int
|
||||||
|
) -> tuple[float, int]:
|
||||||
|
"""Index + perpendicular distance of the farthest point in ``[lo, hi]``."""
|
||||||
|
x0, y0 = points[lo]
|
||||||
|
xn, yn = points[hi]
|
||||||
|
dx, dy = xn - x0, yn - y0
|
||||||
|
seg_len_sq = dx * dx + dy * dy
|
||||||
|
|
||||||
|
max_dist = -1.0
|
||||||
|
max_idx = lo
|
||||||
|
for i in range(lo + 1, hi):
|
||||||
|
xi, yi = points[i]
|
||||||
|
if seg_len_sq == 0.0:
|
||||||
|
dist = math.hypot(xi - x0, yi - y0)
|
||||||
|
else:
|
||||||
|
num = abs(dy * xi - dx * yi + xn * y0 - yn * x0)
|
||||||
|
dist = num / math.sqrt(seg_len_sq)
|
||||||
|
if dist > max_dist:
|
||||||
|
max_dist = dist
|
||||||
|
max_idx = i
|
||||||
|
return (max_dist, max_idx)
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_tolerance_dp(
|
||||||
|
points: list[tuple[float, float]],
|
||||||
|
*,
|
||||||
|
max_waypoints: int,
|
||||||
|
) -> list[int]:
|
||||||
|
"""Binary-search the tolerance that yields ``<= max_waypoints`` points."""
|
||||||
|
n = len(points)
|
||||||
|
if n <= max_waypoints:
|
||||||
|
return list(range(n))
|
||||||
|
|
||||||
|
xs = [p[0] for p in points]
|
||||||
|
ys = [p[1] for p in points]
|
||||||
|
upper_bound_m = math.hypot(max(xs) - min(xs), max(ys) - min(ys))
|
||||||
|
if upper_bound_m == 0.0:
|
||||||
|
return [0, n - 1]
|
||||||
|
|
||||||
|
lo, hi = 0.0, upper_bound_m
|
||||||
|
best: list[int] = [0, n - 1]
|
||||||
|
for _ in range(_AUTO_TOLERANCE_MAX_ITERATIONS):
|
||||||
|
mid = (lo + hi) / 2.0
|
||||||
|
kept = _douglas_peucker(points, tolerance_m=mid)
|
||||||
|
if len(kept) <= max_waypoints:
|
||||||
|
best = kept
|
||||||
|
hi = mid
|
||||||
|
else:
|
||||||
|
lo = mid
|
||||||
|
if hi - lo < _AUTO_TOLERANCE_CONVERGENCE_M:
|
||||||
|
break
|
||||||
|
return best
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""AZ-777 Phase 1 smoke tests against the parent-suite satellite-provider.
|
||||||
|
|
||||||
|
Tier-2 only: these tests assume the real `.NET` `satellite-provider`
|
||||||
|
service (and its Postgres) are running in the Jetson e2e compose graph
|
||||||
|
(`docker-compose.test.jetson.yml`). Each test is gated by
|
||||||
|
`RUN_REPLAY_E2E=1` (the env contract the rest of `tests/e2e/replay/`
|
||||||
|
already uses) and `@pytest.mark.tier2` so dev-laptop pytest runs auto-skip.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
"""AZ-777 Phase 1 smoke test against the real parent-suite satellite-provider.
|
||||||
|
|
||||||
|
Validates the wire C11 just plumbed through against the .NET service
|
||||||
|
running inside `docker-compose.test.jetson.yml`:
|
||||||
|
|
||||||
|
* TLS handshake works against the self-signed dev cert via
|
||||||
|
``SATELLITE_PROVIDER_TLS_INSECURE=1`` (development-only override —
|
||||||
|
production deploys MUST use a CA-issued cert and unset the flag).
|
||||||
|
* Bearer JWT minted from ``JWT_SECRET`` / ``JWT_ISSUER`` /
|
||||||
|
``JWT_AUDIENCE`` is accepted.
|
||||||
|
* ``POST /api/satellite/tiles/inventory`` (AZ-505) round-trips the
|
||||||
|
documented v1.0.0 schema for a 1-tile Derkachi-bbox query: response
|
||||||
|
is 200 with a ``results`` array of length == request.tiles.length
|
||||||
|
(Inv-2 from ``tile-inventory.md``) and per-entry shape matches the
|
||||||
|
contract regardless of seed state.
|
||||||
|
* The adapted C11 :class:`HttpTileDownloader` (Phase 1a) drives the
|
||||||
|
same wire against the real service. When the catalog is unseeded
|
||||||
|
(pre-Phase-2) the report is ``SUCCESS`` with zero downloads (every
|
||||||
|
entry comes back ``present=false``); when seeded, the stubbed C6
|
||||||
|
``write_tile_for_download`` receives one call per present tile and
|
||||||
|
the report counts match.
|
||||||
|
|
||||||
|
Phase 2 (Derkachi catalog seed via ``POST /api/satellite/request``)
|
||||||
|
turns the second test from a "wire works" check into a "tiles actually
|
||||||
|
land" check; the assertions here are written so both states (seeded /
|
||||||
|
unseeded) pass cleanly so the smoke runs green as soon as Phase 1
|
||||||
|
lands and stays green through Phase 2.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import ssl
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from gps_denied_onboard.components.c11_tile_manager import (
|
||||||
|
C11Config,
|
||||||
|
DownloadOutcome,
|
||||||
|
DownloadRequest,
|
||||||
|
HttpTileDownloader,
|
||||||
|
SectorClassification,
|
||||||
|
)
|
||||||
|
from gps_denied_onboard.helpers.wgs_converter import WgsConverter
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# Skip gates
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _heavy_skip_reason() -> str | None:
|
||||||
|
if os.environ.get("RUN_REPLAY_E2E", "").lower() not in {"1", "true", "yes", "on"}:
|
||||||
|
return "AZ-777 satellite-provider smoke gated by RUN_REPLAY_E2E=1"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_HEAVY_SKIP = pytest.mark.skipif(
|
||||||
|
_heavy_skip_reason() is not None,
|
||||||
|
reason=_heavy_skip_reason() or "ok",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_INVENTORY_PATH = "/api/satellite/tiles/inventory"
|
||||||
|
# Derkachi bbox center used for the 1-tile inventory query. Zoom 15 is
|
||||||
|
# the upper bound of the AZ-777 Phase 2 catalog (zooms 15-18); picking
|
||||||
|
# the lowest zoom keeps the per-tile ground footprint largest, which
|
||||||
|
# makes a 1-tile query cover the most catalog.
|
||||||
|
_DERKACHI_TILE_ZOOM = 15
|
||||||
|
_DERKACHI_LAT = 50.10
|
||||||
|
_DERKACHI_LON = 36.10
|
||||||
|
# Tight bbox that surrounds the Derkachi tile center so the C11 bbox
|
||||||
|
# enumeration produces exactly one (z,x,y) coord at zoom 15.
|
||||||
|
_DERKACHI_BBOX = (50.099, 36.099, 50.101, 36.101)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _mint_bearer_token_or_skip() -> str:
|
||||||
|
"""Return a valid Bearer JWT or `pytest.skip` with an actionable reason."""
|
||||||
|
|
||||||
|
token = os.environ.get("SATELLITE_PROVIDER_API_KEY", "").strip()
|
||||||
|
if token and token != "PASTE-MINTED-JWT-HERE":
|
||||||
|
return token
|
||||||
|
try:
|
||||||
|
import jwt # type: ignore[import-untyped]
|
||||||
|
except ImportError:
|
||||||
|
pytest.skip(
|
||||||
|
"SATELLITE_PROVIDER_API_KEY is unset and PyJWT is not "
|
||||||
|
"installed in-container; install dev extras "
|
||||||
|
"(`pip install -e .[dev]`) or set "
|
||||||
|
"SATELLITE_PROVIDER_API_KEY via .env.test."
|
||||||
|
)
|
||||||
|
secret = os.environ.get("JWT_SECRET", "").strip()
|
||||||
|
issuer = os.environ.get("JWT_ISSUER", "").strip()
|
||||||
|
audience = os.environ.get("JWT_AUDIENCE", "").strip()
|
||||||
|
missing = [
|
||||||
|
name
|
||||||
|
for name, value in [
|
||||||
|
("JWT_SECRET", secret),
|
||||||
|
("JWT_ISSUER", issuer),
|
||||||
|
("JWT_AUDIENCE", audience),
|
||||||
|
]
|
||||||
|
if not value
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
pytest.skip("Cannot mint a fallback JWT — missing env: " + ", ".join(missing))
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
payload = {
|
||||||
|
"sub": "gps-denied-onboard-smoke",
|
||||||
|
"iss": issuer,
|
||||||
|
"aud": audience,
|
||||||
|
"jti": uuid.uuid4().hex,
|
||||||
|
"iat": int(now.timestamp()),
|
||||||
|
"nbf": int(now.timestamp()),
|
||||||
|
"exp": int((now + timedelta(hours=1)).timestamp()),
|
||||||
|
}
|
||||||
|
return str(jwt.encode(payload, secret, algorithm="HS256"))
|
||||||
|
|
||||||
|
|
||||||
|
def _make_http_client(base_url: str) -> httpx.Client:
|
||||||
|
"""Return an httpx.Client honouring SATELLITE_PROVIDER_TLS_INSECURE."""
|
||||||
|
|
||||||
|
insecure_raw = os.environ.get("SATELLITE_PROVIDER_TLS_INSECURE", "").strip()
|
||||||
|
insecure = insecure_raw.lower() in {"1", "true", "yes", "on"}
|
||||||
|
verify: bool | ssl.SSLContext
|
||||||
|
if insecure:
|
||||||
|
# Documented dev-only path; the audit-trail WARNING lives in
|
||||||
|
# `.env.test.example`. We do NOT silently disable verification
|
||||||
|
# unless the env var explicitly opts in.
|
||||||
|
verify = False
|
||||||
|
else:
|
||||||
|
verify = True
|
||||||
|
return httpx.Client(base_url=base_url, verify=verify, timeout=30.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_satellite_provider_url() -> str:
|
||||||
|
url = os.environ.get("SATELLITE_PROVIDER_URL", "").strip()
|
||||||
|
if not url:
|
||||||
|
pytest.skip(
|
||||||
|
"SATELLITE_PROVIDER_URL is not set — the Jetson e2e compose "
|
||||||
|
"env block provides https://satellite-provider:8080; running "
|
||||||
|
"this smoke outside compose requires an explicit override."
|
||||||
|
)
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# Test 1 — inventory POST contract (AZ-505 v1.0.0)
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.tier2
|
||||||
|
@_HEAVY_SKIP
|
||||||
|
def test_smoke_satellite_provider_inventory_contract() -> None:
|
||||||
|
# Arrange
|
||||||
|
base_url = _resolve_satellite_provider_url()
|
||||||
|
bearer = _mint_bearer_token_or_skip()
|
||||||
|
tile_x, tile_y = WgsConverter.latlon_to_tile_xy(
|
||||||
|
_DERKACHI_TILE_ZOOM, _DERKACHI_LAT, _DERKACHI_LON
|
||||||
|
)
|
||||||
|
body = {
|
||||||
|
"tiles": [
|
||||||
|
{
|
||||||
|
"z": _DERKACHI_TILE_ZOOM,
|
||||||
|
"x": tile_x,
|
||||||
|
"y": tile_y,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Act
|
||||||
|
with _make_http_client(base_url) as client:
|
||||||
|
response = client.post(
|
||||||
|
_INVENTORY_PATH,
|
||||||
|
json=body,
|
||||||
|
headers={"Authorization": f"Bearer {bearer}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert — contract invariants from `tile-inventory.md` v1.0.0
|
||||||
|
assert response.status_code == 200, (
|
||||||
|
f"satellite-provider inventory POST returned {response.status_code}: {response.text!r}"
|
||||||
|
)
|
||||||
|
decoded = response.json()
|
||||||
|
assert isinstance(decoded, dict), f"expected JSON object, got {type(decoded)}"
|
||||||
|
assert "results" in decoded, f"missing 'results' key in {decoded!r}"
|
||||||
|
results = decoded["results"]
|
||||||
|
assert isinstance(results, list)
|
||||||
|
assert len(results) == len(body["tiles"]), (
|
||||||
|
# Inv-2: response order/length matches request order/length.
|
||||||
|
f"inventory response length {len(results)} != request length {len(body['tiles'])}"
|
||||||
|
)
|
||||||
|
entry = results[0]
|
||||||
|
assert entry["z"] == _DERKACHI_TILE_ZOOM
|
||||||
|
assert entry["x"] == tile_x
|
||||||
|
assert entry["y"] == tile_y
|
||||||
|
assert "present" in entry, f"missing 'present' in entry {entry!r}"
|
||||||
|
assert isinstance(entry["present"], bool)
|
||||||
|
if entry["present"]:
|
||||||
|
for required in ("id", "capturedAt", "source", "resolutionMPerPx"):
|
||||||
|
assert entry.get(required) is not None, (
|
||||||
|
f"present entry missing required field {required!r}: {entry!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# Test 2 — C11 HttpTileDownloader against the real service
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _InMemoryC6Adapter:
|
||||||
|
"""Implements `_TileWriterLike` + `_BudgetEnforcerLike` in-memory.
|
||||||
|
|
||||||
|
The Phase 1 smoke does NOT exercise the real C6 store (that's
|
||||||
|
Phase 3 of AZ-777). It exercises the C11 wire end-to-end with a
|
||||||
|
stub C6 so the test stays scope-clean.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.write_calls: list[dict[str, Any]] = []
|
||||||
|
self.reserved_bytes: list[int] = []
|
||||||
|
self.exists_calls: list[tuple[int, float, float]] = []
|
||||||
|
|
||||||
|
def write_tile_for_download(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tile_blob: bytes,
|
||||||
|
zoom_level: int,
|
||||||
|
lat: float,
|
||||||
|
lon: float,
|
||||||
|
tile_size_meters: float,
|
||||||
|
tile_size_pixels: int,
|
||||||
|
capture_timestamp: datetime,
|
||||||
|
content_sha256_hex: str,
|
||||||
|
sector_class: str,
|
||||||
|
) -> str:
|
||||||
|
self.write_calls.append(
|
||||||
|
{
|
||||||
|
"zoom_level": zoom_level,
|
||||||
|
"lat": lat,
|
||||||
|
"lon": lon,
|
||||||
|
"tile_blob_len": len(tile_blob),
|
||||||
|
"sector_class": sector_class,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return "fresh"
|
||||||
|
|
||||||
|
def tile_already_present(self, *, zoom_level: int, lat: float, lon: float) -> bool:
|
||||||
|
self.exists_calls.append((zoom_level, lat, lon))
|
||||||
|
return False
|
||||||
|
|
||||||
|
def reserve_headroom(self, needed_bytes: int) -> object:
|
||||||
|
self.reserved_bytes.append(int(needed_bytes))
|
||||||
|
return object()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.tier2
|
||||||
|
@_HEAVY_SKIP
|
||||||
|
def test_smoke_c11_download_via_http_pipeline(tmp_path: Path) -> None:
|
||||||
|
# Arrange
|
||||||
|
base_url = _resolve_satellite_provider_url()
|
||||||
|
bearer = _mint_bearer_token_or_skip()
|
||||||
|
adapter = _InMemoryC6Adapter()
|
||||||
|
cfg = C11Config(
|
||||||
|
satellite_provider_url=base_url,
|
||||||
|
service_api_key=bearer,
|
||||||
|
download_http_timeout_s=30.0,
|
||||||
|
download_max_5xx_retries=2,
|
||||||
|
download_max_retry_after_s=60,
|
||||||
|
download_resolution_floor_m_per_px=0.5,
|
||||||
|
)
|
||||||
|
logger = logging.getLogger("test_az777_smoke")
|
||||||
|
with _make_http_client(base_url) as http_client:
|
||||||
|
downloader = HttpTileDownloader(
|
||||||
|
http_client=http_client,
|
||||||
|
tile_writer=adapter, # type: ignore[arg-type]
|
||||||
|
budget_enforcer=adapter, # type: ignore[arg-type]
|
||||||
|
logger=logger,
|
||||||
|
config=cfg,
|
||||||
|
)
|
||||||
|
request = DownloadRequest(
|
||||||
|
flight_id=uuid.uuid4(),
|
||||||
|
bbox_min_lat=_DERKACHI_BBOX[0],
|
||||||
|
bbox_min_lon=_DERKACHI_BBOX[1],
|
||||||
|
bbox_max_lat=_DERKACHI_BBOX[2],
|
||||||
|
bbox_max_lon=_DERKACHI_BBOX[3],
|
||||||
|
zoom_levels=(_DERKACHI_TILE_ZOOM,),
|
||||||
|
sector_class=SectorClassification.STABLE_REAR,
|
||||||
|
cache_root=tmp_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
report = downloader.download_tiles_for_area(request)
|
||||||
|
|
||||||
|
# Assert — Phase 1 smoke contract: the wire works regardless of
|
||||||
|
# catalog state. Pre-Phase-2 the catalog is empty → 0 downloads.
|
||||||
|
# Post-Phase-2 the catalog is seeded → ≥ 1 download. Both are valid
|
||||||
|
# outcomes for this test; the test fails only on a wire / contract
|
||||||
|
# break (any provider error would have raised before reaching this
|
||||||
|
# assertion block).
|
||||||
|
assert report.outcome == DownloadOutcome.SUCCESS
|
||||||
|
assert report.tiles_requested >= 0
|
||||||
|
assert report.tiles_downloaded == len(adapter.write_calls)
|
||||||
|
assert report.tiles_rejected_resolution >= 0
|
||||||
|
if report.tiles_downloaded > 0:
|
||||||
|
# Phase 2 catalog landed: verify the write actually carried the
|
||||||
|
# tile bytes we'd expect from a real GET /tiles/{z}/{x}/{y}.
|
||||||
|
assert all(call["tile_blob_len"] > 0 for call in adapter.write_calls)
|
||||||
|
assert all(call["zoom_level"] == _DERKACHI_TILE_ZOOM for call in adapter.write_calls)
|
||||||
Vendored
+120
@@ -0,0 +1,120 @@
|
|||||||
|
# Derkachi reference C6 tile catalog — fixture seeding
|
||||||
|
|
||||||
|
**AZ-777 Phase 2 deliverable.** Seeds the parent-suite `satellite-provider` DB with the satellite tiles the C6 reference catalog needs for the Derkachi replay tests (`test_ac3_within_100m_80pct_of_ticks` on AC-4 + `test_az699_real_flight_validation_emits_verdict_and_report` on AC-5).
|
||||||
|
|
||||||
|
## What this folder contains
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `bbox.yaml` | bbox + zoom levels + actual flight extent + imagery source metadata + license attribution + chunking strategy |
|
||||||
|
| `seed_region.py` | Python script that submits `POST /api/satellite/request` to satellite-provider for each (zoom × chunk) and polls until completion |
|
||||||
|
| `README.md` | this file |
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. **Running satellite-provider.** Typically the Jetson e2e harness via `docker-compose.test.jetson.yml` (services `satellite-provider` + `satellite-provider-postgres`). Verify it's up and healthy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh jetson-e2e "docker ps --filter name=satellite --format 'table {{.Names}}\t{{.Status}}'"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **`SATELLITE_PROVIDER_URL`** in `.env.test` (already covered by the AZ-777 Phase 1 wiring).
|
||||||
|
|
||||||
|
3. **`SATELLITE_PROVIDER_API_KEY`** — a valid HS256 JWT signed with the same `JWT_SECRET` the satellite-provider validates against. Mint with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export SATELLITE_PROVIDER_API_KEY="$(python scripts/mint_dev_jwt.py)"
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Google Maps Platform API key** on the satellite-provider side (env `GOOGLE_MAPS_API_KEY` / config `MapConfig__ApiKey`). The satellite-provider uses this to actually download the tiles from Google Maps. The seed script does NOT need this key — it only triggers the producer's async download pipeline.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# from gps-denied-onboard repo root, with satellite-provider running on Jetson:
|
||||||
|
export SATELLITE_PROVIDER_API_KEY="$(python scripts/mint_dev_jwt.py)"
|
||||||
|
python tests/fixtures/derkachi_c6/seed_region.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected runtime: ~5-15 minutes for the full spec bbox (8 region calls × ~30-60s each + inventory verification).
|
||||||
|
|
||||||
|
## Flags
|
||||||
|
|
||||||
|
```
|
||||||
|
--bbox-config PATH override bbox.yaml location
|
||||||
|
--env-file PATH override .env.test fallback location
|
||||||
|
--output-summary PATH write a JSON summary for downstream consumers
|
||||||
|
--dry-run validate config + plan without submitting
|
||||||
|
--right-sized-flight use the actual ~1 km^2 flight extent (98% fewer tiles)
|
||||||
|
--skip-poll submit + return; don't wait for terminal status
|
||||||
|
--skip-inventory-verification skip the final coverage check
|
||||||
|
```
|
||||||
|
|
||||||
|
## bbox sizing — important
|
||||||
|
|
||||||
|
`bbox.yaml` ships TWO bboxes:
|
||||||
|
|
||||||
|
* `bbox`: per AZ-777 spec — covers ~11.1 × 7.14 km (~80 km²) of the Derkachi village area. **~4570 tiles z15-z18 (~57 MB)**. Default seeding target.
|
||||||
|
* `actual_flight_extent`: the real Derkachi flight footprint per `data_imu.csv` — only ~254 × 457m (~0.12 km²) centered at (50.082, 36.110). **~60 tiles z15-z18 (~1 MB)** if seeded right-sized via `--right-sized-flight`.
|
||||||
|
|
||||||
|
The spec bbox is ~300× larger than the actual flight extent. The spec sizing is intentional generality — operators can fly any route within the box without re-seeding. The right-sized mode is appropriate when only the specific Derkachi clip needs coverage (e.g., CI test runs).
|
||||||
|
|
||||||
|
## Imagery source — IMPORTANT licensing note
|
||||||
|
|
||||||
|
AZ-777 was originally specced with **CARTO Voyager Basemap (CC-BY-3.0)** as the upstream imagery source. The 2026-05-22 black-box probe of the running satellite-provider revealed the actual upstream is **Google Maps satellite layer** (`mt0..mt3.google.com/vt/lyrs=s`). The AZ-777 spec was amended to reflect this reality (see Risk 4 in `_docs/02_tasks/todo/AZ-777_derkachi_c6_reference_fixture.md`).
|
||||||
|
|
||||||
|
**Operators MUST propagate the attribution string `"Imagery © Google"` to any end-user-visible context** that incorporates tiles seeded by this script. Per `bbox.yaml::license`.
|
||||||
|
|
||||||
|
**Dev/research use is approved. Production deploy requires either:**
|
||||||
|
|
||||||
|
1. Google Maps Platform licensing review for the offline-cache use case (the C6 reference dataset is a long-lived stored cache, which Google Maps ToS may restrict), OR
|
||||||
|
2. A parent-suite ticket to add a true CC-BY satellite imagery provider to satellite-provider (candidates: Esri World Imagery, Mapbox satellite, Sentinel-2 via Copernicus). TBD; not in scope for AZ-777.
|
||||||
|
|
||||||
|
## Re-seeding (after a satellite-provider DB wipe)
|
||||||
|
|
||||||
|
The script is **idempotent and safe to re-run**:
|
||||||
|
|
||||||
|
* Each invocation generates fresh region UUIDs, so each run creates a new set of region records on the producer side.
|
||||||
|
* The producer's tile-storage layer dedups via UPSERT on (zoom, x, y), so tiles already downloaded from Google Maps are NOT re-fetched — they're counted as `tilesReused` instead.
|
||||||
|
* Re-runs are cheap (just the region-tracking overhead) when the DB is warm.
|
||||||
|
|
||||||
|
To verify the catalog is populated without re-running the full seed, query inventory directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# inside the satellite-provider docker network:
|
||||||
|
docker run --rm --network gps-denied-onboard_default curlimages/curl:8.10.1 \
|
||||||
|
-sk -X POST -H "Authorization: Bearer $SATELLITE_PROVIDER_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"tiles":[{"z":18,"x":157497,"y":89000}]}' \
|
||||||
|
https://satellite-provider:8080/api/satellite/tiles/inventory
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cost expectations
|
||||||
|
|
||||||
|
Per the 2026-05-22 probe baseline (200m @ z18 = 9 tiles, ~13 KB/tile, ~5s end-to-end):
|
||||||
|
|
||||||
|
| Mode | Tile count | DB size | Wall time (cold) | Wall time (warm DB) |
|
||||||
|
|------|------------|---------|------------------|---------------------|
|
||||||
|
| Spec bbox (~80 km²) | ~4570 | ~57 MB | ~5-15 min | ~30s (reuse) |
|
||||||
|
| Right-sized (~1 km²) | ~60 | ~1 MB | ~1-2 min | ~10s (reuse) |
|
||||||
|
|
||||||
|
Google Maps API cost per tile depends on the satellite-provider operator's Maps Platform pricing tier. The seed script does NOT bill — the producer's Google Maps account does.
|
||||||
|
|
||||||
|
## Failure modes
|
||||||
|
|
||||||
|
| Exit code | Meaning | Likely cause |
|
||||||
|
|-----------|---------|--------------|
|
||||||
|
| 71 | config file missing / malformed | `bbox.yaml` corrupted or wrong path |
|
||||||
|
| 72 | required env var missing | `SATELLITE_PROVIDER_URL` or `SATELLITE_PROVIDER_API_KEY` not set |
|
||||||
|
| 73 | satellite-provider unreachable | Service down, wrong URL, or TLS handshake failed (try `SATELLITE_PROVIDER_TLS_INSECURE=1`) |
|
||||||
|
| 74 | region request rejected | HTTP 4xx (auth, validation) or 5xx (producer crash); see stderr for HTTP body |
|
||||||
|
| 75 | one or more regions failed | Background processing failed — usually a Google Maps API quota / key issue on the producer side. Check `docker logs gps-denied-e2e-satellite-provider` |
|
||||||
|
| 76 | inventory verification mismatch | < 95% of expected tiles present; re-run to retry, or investigate producer logs |
|
||||||
|
|
||||||
|
## Cross-references
|
||||||
|
|
||||||
|
* AZ-777 spec: `_docs/02_tasks/todo/AZ-777_derkachi_c6_reference_fixture.md`
|
||||||
|
* AZ-777 Phase 1 (the wiring that makes this script callable): completed cycle 3 batch 105
|
||||||
|
* AZ-808 (parent-suite): strict validation for region-request endpoint — when this lands, malformed `seed_region.py` invocations will fail with RFC 7807 ValidationProblemDetails instead of silent zero-coercion; coordinate any consumer-side changes with that release
|
||||||
|
* AZ-812 (parent-suite): rename `RequestRegionRequest.{Latitude, Longitude}` → `{Lat, Lon}` for OSM consistency — when this lands, `seed_region.py` must be updated to send `lat`/`lon` instead of `latitude`/`longitude`
|
||||||
|
* satellite-provider Region API contract: today informally documented in `../../../../satellite-provider/_docs/02_document/modules/common_dtos.md::RegionRequest` + `system-flows.md` Flow F2; formal `region-request.md` contract will be published as part of AZ-808
|
||||||
Vendored
+158
@@ -0,0 +1,158 @@
|
|||||||
|
# Derkachi reference tile catalog — bbox + zoom + imagery source metadata
|
||||||
|
#
|
||||||
|
# This file drives `seed_region.py`, which calls the parent-suite
|
||||||
|
# `satellite-provider` Region API (`POST /api/satellite/request`) to seed
|
||||||
|
# the satellite-provider DB with the Derkachi tiles the C6 reference
|
||||||
|
# catalog needs for AZ-777 Phase 2+.
|
||||||
|
#
|
||||||
|
# Authoritative spec: ../_docs/02_tasks/todo/AZ-777_derkachi_c6_reference_fixture.md
|
||||||
|
# Probe-confirmed wire format (2026-05-22): {id, latitude, longitude,
|
||||||
|
# sizeMeters, zoomLevel, stitchTiles} — see AZ-812 for the planned
|
||||||
|
# latitude/longitude -> lat/lon OSM rename.
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# Bounding box — per AZ-777 spec
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# Spec bbox covers the Derkachi village area generously so the operator
|
||||||
|
# can fly any route within the box without re-seeding the catalog. This
|
||||||
|
# is INTENTIONALLY larger than the specific Derkachi flight extent (see
|
||||||
|
# `actual_flight_extent` below for the real per-flight footprint).
|
||||||
|
bbox:
|
||||||
|
lat_min: 50.05
|
||||||
|
lat_max: 50.15
|
||||||
|
lon_min: 36.05
|
||||||
|
lon_max: 36.15
|
||||||
|
# Approximate at lat ~50: 11.1 km (lat) x 7.14 km (lon) = ~79 km^2
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# Actual Derkachi flight extent (derived from data_imu.csv, 2026-05-22)
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# The real flight is much smaller than the spec bbox. Documented here
|
||||||
|
# for transparency. If only the specific Derkachi clip needs coverage,
|
||||||
|
# re-running seed_region.py with `--right-sized-flight` would seed
|
||||||
|
# ~60 tiles instead of ~4570 tiles (~98% reduction).
|
||||||
|
actual_flight_extent:
|
||||||
|
source: _docs/00_problem/input_data/flight_derkachi/data_imu.csv
|
||||||
|
lat_min: 50.080870
|
||||||
|
lat_max: 50.083159
|
||||||
|
lon_min: 36.107000
|
||||||
|
lon_max: 36.113401
|
||||||
|
# ~254 m (lat) x ~457 m (lon), centered at (50.082, 36.110)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# Zoom levels — per AZ-777 spec
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
zoom_levels:
|
||||||
|
- 15 # ~5 m / pixel — coarse coverage
|
||||||
|
- 16 # ~2.4 m / pixel — mid-altitude search
|
||||||
|
- 17 # ~1.2 m / pixel — close-altitude search
|
||||||
|
- 18 # ~0.6 m / pixel — VPR descriptor lock (primary)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# Region API chunking strategy
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# Region API caps `sizeMeters` at 10000 (10 km side). The Derkachi bbox
|
||||||
|
# is 11.1 km along the lat axis, so a single region call cannot cover
|
||||||
|
# the full bbox. seed_region.py splits the bbox into N north-south
|
||||||
|
# chunks per zoom level (default: 2 chunks, each centered to cover the
|
||||||
|
# corresponding half of the bbox with sizeMeters=10000). Overlap is
|
||||||
|
# deduplicated server-side by the producer's UPSERT-on-tile-coord
|
||||||
|
# behavior so we do not pay Google Maps cost twice for overlapping
|
||||||
|
# tiles.
|
||||||
|
chunking:
|
||||||
|
chunks_per_zoom: 2
|
||||||
|
size_meters_per_chunk: 10000
|
||||||
|
stitch_tiles: false # individual tiles, not stitched composite
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# Imagery source — IMPORTANT: amended 2026-05-22 (see AZ-777 risk 4)
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# AZ-777 was originally specced with CARTO Voyager Basemap (CC-BY-3.0)
|
||||||
|
# as the upstream imagery source. The 2026-05-22 black-box probe of
|
||||||
|
# the running satellite-provider revealed the actual upstream is Google
|
||||||
|
# Maps (verified via satellite-provider/_docs/02_document/architecture.md
|
||||||
|
# line 49 + producer logs: `mt0..mt3.google.com/vt/lyrs=s`).
|
||||||
|
#
|
||||||
|
# Dev/research use is acceptable. Production deploy requires either:
|
||||||
|
# (a) Google Maps Platform licensing review for offline-cache use, OR
|
||||||
|
# (b) parent-suite ticket to add a true CC-BY satellite imagery
|
||||||
|
# provider to satellite-provider (Esri World Imagery, Mapbox
|
||||||
|
# satellite, Sentinel-2 via Copernicus, etc.).
|
||||||
|
imagery_source:
|
||||||
|
provider: google_maps
|
||||||
|
layer: lyrs=s # Google Maps satellite layer (high-resolution overhead)
|
||||||
|
resolution_at_z18_lat50_m_per_px: 0.384 # probe-measured 2026-05-22
|
||||||
|
fetch_pattern: mt[0-3].google.com/vt/lyrs=s&x={x}&y={y}&z={z}&token={session_token}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# License attribution (operators must propagate to end users)
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
license:
|
||||||
|
source: Google Maps Platform Terms of Service
|
||||||
|
url: https://cloud.google.com/maps-platform/terms
|
||||||
|
attribution_text: "Imagery © Google"
|
||||||
|
dev_use_only: true
|
||||||
|
production_warning: |
|
||||||
|
The Google Maps Platform ToS may restrict offline caching of map
|
||||||
|
tiles for use as a derivative reference dataset (e.g. a long-lived
|
||||||
|
VPR reference base). This catalog is approved for dev/research use
|
||||||
|
only. Before any production deployment, EITHER:
|
||||||
|
1. Obtain Google Maps Platform licensing approval for the C6
|
||||||
|
offline-cache use case, OR
|
||||||
|
2. Migrate to a CC-BY satellite imagery provider on the
|
||||||
|
satellite-provider side (parent-suite ticket TBD).
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# Catalog size budget — OVER BUDGET WARNING
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# Per AZ-777 spec line 178: "The seeded Derkachi catalog size budget is
|
||||||
|
# 100 MB on the satellite-provider DB side. Over budget -> reduce
|
||||||
|
# zoom-level coverage; document in bbox.yaml."
|
||||||
|
#
|
||||||
|
# Actual estimate against the spec bbox + zoom 15-18: ~11386 tiles
|
||||||
|
# (per seed_region.py --dry-run), ~148 MB (at probe-measured 13 KB/tile
|
||||||
|
# avg). This is ~48% OVER the spec's 100 MB budget.
|
||||||
|
#
|
||||||
|
# The original spec budget was likely calibrated for CARTO Voyager
|
||||||
|
# tiles, which are smaller than the Google Maps satellite tiles we
|
||||||
|
# actually fetch (CARTO is street-feature vector overlay; Google
|
||||||
|
# satellite is high-detail overhead JPEG). The reality is heavier.
|
||||||
|
#
|
||||||
|
# Three mitigation options (operator picks at run time):
|
||||||
|
# 1. Drop z=18 from `zoom_levels` -> ~3000 tiles, ~40 MB (in budget),
|
||||||
|
# but loses primary VPR descriptor-lock zoom. NOT RECOMMENDED for
|
||||||
|
# AC-4 / AZ-699 passes.
|
||||||
|
# 2. Reduce bbox -> e.g. 5x5 km tight to the flight cluster instead
|
||||||
|
# of the full village area. Coverage shrinks proportionally.
|
||||||
|
# 3. Use `--right-sized-flight` -> ~60 tiles, ~1 MB. Tight to the
|
||||||
|
# specific Derkachi clip; cannot fly an alternative path within
|
||||||
|
# the original spec bbox without re-seeding.
|
||||||
|
#
|
||||||
|
# Default behavior (no flag): seed the full spec bbox EVEN THOUGH IT
|
||||||
|
# EXCEEDS THE BUDGET. seed_region.py will print a loud warning at the
|
||||||
|
# start of the run if the estimated size is over budget, and the
|
||||||
|
# operator can interrupt + re-run with one of the mitigations above.
|
||||||
|
catalog_size_budget:
|
||||||
|
max_bytes_db_side: 104857600 # 100 MB (spec budget)
|
||||||
|
estimated_tile_count_spec_bbox: 11386
|
||||||
|
estimated_tile_count_right_sized: 60
|
||||||
|
estimated_avg_bytes_per_tile: 13046 # probe-measured 2026-05-22
|
||||||
|
estimated_total_bytes_spec_bbox: 148581256 # ~141.7 MB (~48% OVER)
|
||||||
|
estimated_total_bytes_right_sized: 782760 # ~0.75 MB
|
||||||
|
over_budget_warning: |
|
||||||
|
Default spec bbox seeding exceeds the spec's 100 MB budget by ~48%
|
||||||
|
when seeding all 4 zoom levels (15-18) with Google Maps satellite
|
||||||
|
imagery. Operator must choose: accept the overage, drop a zoom
|
||||||
|
level, reduce the bbox, or use --right-sized-flight.
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# Provenance
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
provenance:
|
||||||
|
created_for: AZ-777 (gps-denied-onboard Derkachi C6 reference fixture)
|
||||||
|
bbox_source: AZ-777 spec outcome line 31 (50.05-50.15 lat, 36.05-36.15 lon)
|
||||||
|
flight_extent_source: data_imu.csv computed 2026-05-22
|
||||||
|
api_contract_source: probe-confirmed against running satellite-provider on Jetson, 2026-05-22
|
||||||
|
related_tickets:
|
||||||
|
- AZ-808 # validation for region-request endpoint
|
||||||
|
- AZ-812 # rename latitude/longitude -> lat/lon (consumer must update after AZ-812 lands)
|
||||||
+522
@@ -0,0 +1,522 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Seed the Derkachi reference tile catalog via satellite-provider's Region API.
|
||||||
|
|
||||||
|
AZ-777 Phase 2 deliverable. Reads ``bbox.yaml`` next to this script and
|
||||||
|
submits one or more ``POST /api/satellite/request`` calls per zoom level
|
||||||
|
to register the Derkachi bbox with the parent-suite satellite-provider.
|
||||||
|
Polls each region's status until terminal, then verifies the expected
|
||||||
|
tile count via ``POST /api/satellite/tiles/inventory``.
|
||||||
|
|
||||||
|
This script is intended to run from the gps-denied-onboard repo root
|
||||||
|
against a running satellite-provider (typically the Jetson e2e harness's
|
||||||
|
``satellite-provider`` service). It does NOT spin up the service itself
|
||||||
|
and does NOT modify any satellite-provider code or configuration.
|
||||||
|
|
||||||
|
Required environment (loaded from ``.env.test`` if not exported)::
|
||||||
|
|
||||||
|
SATELLITE_PROVIDER_URL e.g. https://satellite-provider:8080
|
||||||
|
SATELLITE_PROVIDER_API_KEY a valid HS256 JWT (mint with scripts/mint_dev_jwt.py)
|
||||||
|
SATELLITE_PROVIDER_TLS_INSECURE optional, "1" to accept self-signed dev certs
|
||||||
|
JWT_SECRET / JWT_ISSUER / JWT_AUDIENCE required only if --auto-mint-jwt is passed
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
# mint a JWT then seed using defaults from bbox.yaml
|
||||||
|
export SATELLITE_PROVIDER_API_KEY="$(python scripts/mint_dev_jwt.py)"
|
||||||
|
python tests/fixtures/derkachi_c6/seed_region.py
|
||||||
|
|
||||||
|
# dry-run (validate config + auth without submitting requests)
|
||||||
|
python tests/fixtures/derkachi_c6/seed_region.py --dry-run
|
||||||
|
|
||||||
|
# right-sized to actual flight extent (faster, fewer tiles)
|
||||||
|
python tests/fixtures/derkachi_c6/seed_region.py --right-sized-flight
|
||||||
|
|
||||||
|
# write a JSON summary for downstream consumers (fixture / CI)
|
||||||
|
python tests/fixtures/derkachi_c6/seed_region.py --output-summary /tmp/seed.json
|
||||||
|
|
||||||
|
Exit codes::
|
||||||
|
|
||||||
|
0 all regions reached terminal status and inventory verification passed
|
||||||
|
71 config file missing / malformed
|
||||||
|
72 required env var missing
|
||||||
|
73 satellite-provider unreachable (TCP / TLS error)
|
||||||
|
74 region request rejected (HTTP 4xx / 5xx)
|
||||||
|
75 one or more regions failed during background processing
|
||||||
|
76 inventory verification mismatch (fewer tiles present than expected)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
except ImportError as exc:
|
||||||
|
sys.stderr.write(
|
||||||
|
f"ERROR: httpx not installed: {exc}\nRun `pip install -e .[dev]` from the repo root.\n"
|
||||||
|
)
|
||||||
|
sys.exit(72)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
except ImportError as exc:
|
||||||
|
sys.stderr.write(
|
||||||
|
f"ERROR: PyYAML not installed: {exc}\nRun `pip install -e .[dev]` from the repo root.\n"
|
||||||
|
)
|
||||||
|
sys.exit(72)
|
||||||
|
|
||||||
|
|
||||||
|
_REQUEST_TIMEOUT_S = 30.0
|
||||||
|
_POLL_INTERVAL_S = 5.0
|
||||||
|
_POLL_MAX_ATTEMPTS = 60 # 60 * 5s = 5 min per region
|
||||||
|
_TERMINAL_STATUSES = frozenset({"completed", "failed", "error", "done", "succeeded"})
|
||||||
|
_FAILURE_STATUSES = frozenset({"failed", "error"})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RegionChunk:
|
||||||
|
"""One Region API submission: a square area at one zoom level."""
|
||||||
|
|
||||||
|
zoom: int
|
||||||
|
center_lat: float
|
||||||
|
center_lon: float
|
||||||
|
size_meters: int
|
||||||
|
chunk_label: str # e.g. "z18-north" — for human-readable logs only
|
||||||
|
region_id: uuid.UUID = field(default_factory=uuid.uuid4)
|
||||||
|
submitted_status: str | None = None
|
||||||
|
terminal_status: str | None = None
|
||||||
|
tiles_downloaded: int = 0
|
||||||
|
tiles_reused: int = 0
|
||||||
|
csv_path: str | None = None
|
||||||
|
summary_path: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _load_env_file(path: Path) -> dict[str, str]:
|
||||||
|
"""Parse a KEY=VALUE env file. Honours quoting; ignores comments."""
|
||||||
|
|
||||||
|
if not path.is_file():
|
||||||
|
return {}
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
for raw in path.read_text("utf-8").splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
out[key.strip()] = value.strip().strip('"').strip("'")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_env(name: str, env_file_values: dict[str, str]) -> str | None:
|
||||||
|
return os.environ.get(name) or env_file_values.get(name)
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_chunks(config: dict[str, Any], right_sized: bool) -> list[RegionChunk]:
|
||||||
|
"""Plan all Region API submissions for one seeding pass.
|
||||||
|
|
||||||
|
Splits each zoom level into N chunks across the lat axis so each
|
||||||
|
chunk fits within the Region API's sizeMeters cap (10000).
|
||||||
|
"""
|
||||||
|
|
||||||
|
if right_sized:
|
||||||
|
bbox = config["actual_flight_extent"]
|
||||||
|
else:
|
||||||
|
bbox = config["bbox"]
|
||||||
|
chunks_per_zoom = config["chunking"]["chunks_per_zoom"]
|
||||||
|
size_meters = int(config["chunking"]["size_meters_per_chunk"])
|
||||||
|
zoom_levels = config["zoom_levels"]
|
||||||
|
|
||||||
|
if right_sized:
|
||||||
|
# The flight extent is < 1 km, one chunk per zoom is sufficient.
|
||||||
|
chunks_per_zoom = 1
|
||||||
|
size_meters = 1000
|
||||||
|
|
||||||
|
lat_centers: list[float]
|
||||||
|
if chunks_per_zoom == 1:
|
||||||
|
lat_centers = [(bbox["lat_min"] + bbox["lat_max"]) / 2.0]
|
||||||
|
else:
|
||||||
|
span = bbox["lat_max"] - bbox["lat_min"]
|
||||||
|
step = span / chunks_per_zoom
|
||||||
|
lat_centers = [bbox["lat_min"] + step * (i + 0.5) for i in range(chunks_per_zoom)]
|
||||||
|
center_lon = (bbox["lon_min"] + bbox["lon_max"]) / 2.0
|
||||||
|
|
||||||
|
chunks: list[RegionChunk] = []
|
||||||
|
for zoom in zoom_levels:
|
||||||
|
for idx, lat in enumerate(lat_centers):
|
||||||
|
label_suffix = f"chunk{idx}" if chunks_per_zoom > 1 else "single"
|
||||||
|
chunks.append(
|
||||||
|
RegionChunk(
|
||||||
|
zoom=zoom,
|
||||||
|
center_lat=lat,
|
||||||
|
center_lon=center_lon,
|
||||||
|
size_meters=size_meters,
|
||||||
|
chunk_label=f"z{zoom}-{label_suffix}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
def _expected_tile_coords(config: dict[str, Any], right_sized: bool) -> list[tuple[int, int, int]]:
|
||||||
|
"""Compute the slippy-map (z, x, y) tile coords covering the bbox.
|
||||||
|
|
||||||
|
Used by the inventory verification step.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if right_sized:
|
||||||
|
bbox = config["actual_flight_extent"]
|
||||||
|
else:
|
||||||
|
bbox = config["bbox"]
|
||||||
|
coords: list[tuple[int, int, int]] = []
|
||||||
|
for z in config["zoom_levels"]:
|
||||||
|
n = 2**z
|
||||||
|
x_min = int((bbox["lon_min"] + 180) / 360 * n)
|
||||||
|
x_max = int((bbox["lon_max"] + 180) / 360 * n)
|
||||||
|
y_min = int((1 - math.asinh(math.tan(math.radians(bbox["lat_max"]))) / math.pi) / 2 * n)
|
||||||
|
y_max = int((1 - math.asinh(math.tan(math.radians(bbox["lat_min"]))) / math.pi) / 2 * n)
|
||||||
|
for x in range(x_min, x_max + 1):
|
||||||
|
for y in range(y_min, y_max + 1):
|
||||||
|
coords.append((z, x, y))
|
||||||
|
return coords
|
||||||
|
|
||||||
|
|
||||||
|
def _submit_region(
|
||||||
|
client: httpx.Client, sp_url: str, headers: dict[str, str], chunk: RegionChunk
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
"""Submit one Region API request. Returns (success, message)."""
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"id": str(chunk.region_id),
|
||||||
|
"latitude": chunk.center_lat,
|
||||||
|
"longitude": chunk.center_lon,
|
||||||
|
"sizeMeters": chunk.size_meters,
|
||||||
|
"zoomLevel": chunk.zoom,
|
||||||
|
"stitchTiles": False,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
resp = client.post(
|
||||||
|
f"{sp_url}/api/satellite/request",
|
||||||
|
headers=headers,
|
||||||
|
json=body,
|
||||||
|
timeout=_REQUEST_TIMEOUT_S,
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
return False, f"network error: {exc}"
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return False, f"HTTP {resp.status_code}: {resp.text[:200]}"
|
||||||
|
try:
|
||||||
|
payload = resp.json()
|
||||||
|
chunk.submitted_status = payload.get("status")
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
return False, f"unexpected response body (not JSON): {exc}; raw={resp.text[:200]}"
|
||||||
|
return True, f"submitted; initial status={chunk.submitted_status}"
|
||||||
|
|
||||||
|
|
||||||
|
def _poll_region(
|
||||||
|
client: httpx.Client, sp_url: str, headers: dict[str, str], chunk: RegionChunk
|
||||||
|
) -> str:
|
||||||
|
"""Poll one Region until terminal status. Updates chunk fields in-place.
|
||||||
|
|
||||||
|
Returns the final status string. Raises RuntimeError on timeout.
|
||||||
|
"""
|
||||||
|
|
||||||
|
for attempt in range(1, _POLL_MAX_ATTEMPTS + 1):
|
||||||
|
try:
|
||||||
|
resp = client.get(
|
||||||
|
f"{sp_url}/api/satellite/region/{chunk.region_id}",
|
||||||
|
headers=headers,
|
||||||
|
timeout=_REQUEST_TIMEOUT_S,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
payload = resp.json()
|
||||||
|
except (httpx.HTTPError, json.JSONDecodeError) as exc:
|
||||||
|
sys.stderr.write(f" [{chunk.chunk_label}] poll attempt {attempt} failed: {exc}\n")
|
||||||
|
time.sleep(_POLL_INTERVAL_S)
|
||||||
|
continue
|
||||||
|
status = (payload.get("status") or "").lower()
|
||||||
|
chunk.terminal_status = status
|
||||||
|
chunk.tiles_downloaded = payload.get("tilesDownloaded", 0)
|
||||||
|
chunk.tiles_reused = payload.get("tilesReused", 0)
|
||||||
|
chunk.csv_path = payload.get("csvFilePath")
|
||||||
|
chunk.summary_path = payload.get("summaryFilePath")
|
||||||
|
if status in _TERMINAL_STATUSES:
|
||||||
|
return status
|
||||||
|
if attempt % 6 == 0: # every ~30s
|
||||||
|
sys.stderr.write(
|
||||||
|
f" [{chunk.chunk_label}] still {status} (attempt {attempt}/{_POLL_MAX_ATTEMPTS})\n"
|
||||||
|
)
|
||||||
|
time.sleep(_POLL_INTERVAL_S)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"region {chunk.region_id} ({chunk.chunk_label}) did not reach terminal "
|
||||||
|
f"status within {_POLL_MAX_ATTEMPTS * _POLL_INTERVAL_S:.0f}s"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_inventory(
|
||||||
|
client: httpx.Client,
|
||||||
|
sp_url: str,
|
||||||
|
headers: dict[str, str],
|
||||||
|
expected_coords: list[tuple[int, int, int]],
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
"""Query inventory for the expected tile coords. Returns (present, total)."""
|
||||||
|
|
||||||
|
BATCH_SIZE = 5000
|
||||||
|
total_present = 0
|
||||||
|
total = 0
|
||||||
|
for batch_start in range(0, len(expected_coords), BATCH_SIZE):
|
||||||
|
batch = expected_coords[batch_start : batch_start + BATCH_SIZE]
|
||||||
|
body = {"tiles": [{"z": z, "x": x, "y": y} for z, x, y in batch]}
|
||||||
|
try:
|
||||||
|
resp = client.post(
|
||||||
|
f"{sp_url}/api/satellite/tiles/inventory",
|
||||||
|
headers=headers,
|
||||||
|
json=body,
|
||||||
|
timeout=_REQUEST_TIMEOUT_S,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
payload = resp.json()
|
||||||
|
except (httpx.HTTPError, json.JSONDecodeError) as exc:
|
||||||
|
sys.stderr.write(f"inventory batch starting at {batch_start} failed: {exc}\n")
|
||||||
|
continue
|
||||||
|
results = payload.get("results", [])
|
||||||
|
total += len(results)
|
||||||
|
total_present += sum(1 for r in results if r.get("present"))
|
||||||
|
return total_present, total
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"--bbox-config",
|
||||||
|
type=Path,
|
||||||
|
default=Path(__file__).parent / "bbox.yaml",
|
||||||
|
help="Path to bbox.yaml (default: alongside this script).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--env-file",
|
||||||
|
type=Path,
|
||||||
|
default=Path(".env.test"),
|
||||||
|
help="Fallback env file (default: .env.test in CWD).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output-summary",
|
||||||
|
type=Path,
|
||||||
|
default=None,
|
||||||
|
help="Optional path to write a JSON summary of the seeding run.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Plan + validate auth, but do not submit Region requests.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--right-sized-flight",
|
||||||
|
action="store_true",
|
||||||
|
help=(
|
||||||
|
"Use the actual_flight_extent bbox (~1 km^2) instead of the full "
|
||||||
|
"AZ-777 spec bbox (~80 km^2). ~98%% fewer tiles, useful when only "
|
||||||
|
"the specific Derkachi clip needs coverage."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--skip-poll",
|
||||||
|
action="store_true",
|
||||||
|
help="Submit all regions but do not poll; exit immediately after submission.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--skip-inventory-verification",
|
||||||
|
action="store_true",
|
||||||
|
help="Skip the final inventory verification step.",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not args.bbox_config.is_file():
|
||||||
|
sys.stderr.write(f"ERROR: bbox config not found: {args.bbox_config}\n")
|
||||||
|
return 71
|
||||||
|
try:
|
||||||
|
config = yaml.safe_load(args.bbox_config.read_text("utf-8"))
|
||||||
|
except yaml.YAMLError as exc:
|
||||||
|
sys.stderr.write(f"ERROR: failed to parse {args.bbox_config}: {exc}\n")
|
||||||
|
return 71
|
||||||
|
|
||||||
|
env_file_values = _load_env_file(args.env_file)
|
||||||
|
sp_url = _resolve_env("SATELLITE_PROVIDER_URL", env_file_values)
|
||||||
|
jwt_token = _resolve_env("SATELLITE_PROVIDER_API_KEY", env_file_values)
|
||||||
|
tls_insecure = _resolve_env("SATELLITE_PROVIDER_TLS_INSECURE", env_file_values) == "1"
|
||||||
|
|
||||||
|
if not sp_url:
|
||||||
|
sys.stderr.write("ERROR: SATELLITE_PROVIDER_URL not set (env or .env.test).\n")
|
||||||
|
return 72
|
||||||
|
if not jwt_token:
|
||||||
|
sys.stderr.write(
|
||||||
|
"ERROR: SATELLITE_PROVIDER_API_KEY not set. Mint with:\n"
|
||||||
|
" python scripts/mint_dev_jwt.py\n"
|
||||||
|
)
|
||||||
|
return 72
|
||||||
|
|
||||||
|
chunks = _compute_chunks(config, args.right_sized_flight)
|
||||||
|
expected_coords = _expected_tile_coords(config, args.right_sized_flight)
|
||||||
|
|
||||||
|
# Budget check — loud warning if over-budget per AZ-777 spec line 178.
|
||||||
|
avg_bytes = int(config["catalog_size_budget"]["estimated_avg_bytes_per_tile"])
|
||||||
|
budget_bytes = int(config["catalog_size_budget"]["max_bytes_db_side"])
|
||||||
|
estimated_total = len(expected_coords) * avg_bytes
|
||||||
|
over_budget = estimated_total > budget_bytes
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"[plan] satellite-provider: {sp_url} (tls_insecure={tls_insecure})\n"
|
||||||
|
f"[plan] bbox mode: {'right-sized flight' if args.right_sized_flight else 'spec bbox (~80 km^2)'}\n"
|
||||||
|
f"[plan] zoom levels: {config['zoom_levels']}\n"
|
||||||
|
f"[plan] region chunks to submit: {len(chunks)}\n"
|
||||||
|
f"[plan] expected tile coverage: {len(expected_coords)} tiles\n"
|
||||||
|
f"[plan] estimated DB size: {estimated_total / 1_048_576:.1f} MB "
|
||||||
|
f"(budget: {budget_bytes / 1_048_576:.0f} MB)\n"
|
||||||
|
f"[plan] imagery source: {config['imagery_source']['provider']}/{config['imagery_source']['layer']}\n"
|
||||||
|
f"[plan] license: {config['license']['source']}\n"
|
||||||
|
f"[plan] attribution: {config['license']['attribution_text']}\n"
|
||||||
|
)
|
||||||
|
if over_budget:
|
||||||
|
overage_pct = (estimated_total - budget_bytes) / budget_bytes * 100
|
||||||
|
sys.stderr.write(
|
||||||
|
"WARNING: estimated DB size exceeds spec budget by "
|
||||||
|
f"~{overage_pct:.0f}%. Per AZ-777 line 178 you can:\n"
|
||||||
|
" - drop a zoom level (edit bbox.yaml::zoom_levels)\n"
|
||||||
|
" - reduce bbox (edit bbox.yaml::bbox)\n"
|
||||||
|
" - use --right-sized-flight (tight to actual flight extent)\n"
|
||||||
|
"Continuing anyway. Use --dry-run to inspect without seeding.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print("[dry-run] would submit:")
|
||||||
|
for c in chunks:
|
||||||
|
print(
|
||||||
|
f" {c.chunk_label}: id={c.region_id} "
|
||||||
|
f"lat={c.center_lat:.5f} lon={c.center_lon:.5f} "
|
||||||
|
f"size={c.size_meters} zoom={c.zoom}"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {jwt_token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
client = httpx.Client(verify=not tls_insecure)
|
||||||
|
try:
|
||||||
|
# ----- Phase A: submit all regions upfront -----
|
||||||
|
print(f"\n[submit] sending {len(chunks)} region requests...")
|
||||||
|
submission_failures: list[tuple[RegionChunk, str]] = []
|
||||||
|
for c in chunks:
|
||||||
|
ok, msg = _submit_region(client, sp_url, headers, c)
|
||||||
|
print(f" [{c.chunk_label}] {msg}")
|
||||||
|
if not ok:
|
||||||
|
submission_failures.append((c, msg))
|
||||||
|
if submission_failures:
|
||||||
|
sys.stderr.write(f"ERROR: {len(submission_failures)} submission(s) failed:\n")
|
||||||
|
for c, msg in submission_failures:
|
||||||
|
sys.stderr.write(f" [{c.chunk_label}] {msg}\n")
|
||||||
|
return 74
|
||||||
|
|
||||||
|
if args.skip_poll:
|
||||||
|
print(
|
||||||
|
"\n[skip-poll] all submissions sent; "
|
||||||
|
"background processing continues asynchronously. "
|
||||||
|
f"Region IDs: {[str(c.region_id) for c in chunks]}"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# ----- Phase B: poll each region until terminal -----
|
||||||
|
print(f"\n[poll] waiting for {len(chunks)} regions to reach terminal status...")
|
||||||
|
poll_failures: list[RegionChunk] = []
|
||||||
|
for c in chunks:
|
||||||
|
try:
|
||||||
|
status = _poll_region(client, sp_url, headers, c)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
sys.stderr.write(f" [{c.chunk_label}] {exc}\n")
|
||||||
|
poll_failures.append(c)
|
||||||
|
continue
|
||||||
|
tiles = c.tiles_downloaded + c.tiles_reused
|
||||||
|
print(
|
||||||
|
f" [{c.chunk_label}] terminal={status} tiles={tiles} "
|
||||||
|
f"(downloaded={c.tiles_downloaded} reused={c.tiles_reused})"
|
||||||
|
)
|
||||||
|
if status in _FAILURE_STATUSES:
|
||||||
|
poll_failures.append(c)
|
||||||
|
if poll_failures:
|
||||||
|
sys.stderr.write(f"ERROR: {len(poll_failures)} region(s) did not complete cleanly\n")
|
||||||
|
return 75
|
||||||
|
|
||||||
|
# ----- Phase C: verify inventory -----
|
||||||
|
if not args.skip_inventory_verification:
|
||||||
|
print(f"\n[inventory] verifying {len(expected_coords)} expected tile coords...")
|
||||||
|
present, queried = _verify_inventory(client, sp_url, headers, expected_coords)
|
||||||
|
print(
|
||||||
|
f"[inventory] present: {present}/{queried} "
|
||||||
|
f"({present / queried * 100:.1f}% coverage)"
|
||||||
|
if queried
|
||||||
|
else "[inventory] no tiles queried"
|
||||||
|
)
|
||||||
|
if queried and present < queried:
|
||||||
|
missing = queried - present
|
||||||
|
sys.stderr.write(
|
||||||
|
f"WARNING: {missing} expected tile(s) not present in inventory. "
|
||||||
|
"This may indicate partial region failures, edge-tile gaps, or "
|
||||||
|
"Google Maps API timeouts. Re-run seed_region.py to fill gaps "
|
||||||
|
"(producer dedups via UPSERT-on-coord, so retries are safe).\n"
|
||||||
|
)
|
||||||
|
if present / queried < 0.95:
|
||||||
|
return 76
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
# ----- Summary output -----
|
||||||
|
total_downloaded = sum(c.tiles_downloaded for c in chunks)
|
||||||
|
total_reused = sum(c.tiles_reused for c in chunks)
|
||||||
|
print(
|
||||||
|
f"\n[done] seeded {len(chunks)} regions: "
|
||||||
|
f"downloaded={total_downloaded} reused={total_reused}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.output_summary:
|
||||||
|
summary = {
|
||||||
|
"sp_url": sp_url,
|
||||||
|
"bbox_mode": "right-sized" if args.right_sized_flight else "spec",
|
||||||
|
"imagery_source": config["imagery_source"],
|
||||||
|
"license": config["license"],
|
||||||
|
"chunks": [
|
||||||
|
{
|
||||||
|
"label": c.chunk_label,
|
||||||
|
"region_id": str(c.region_id),
|
||||||
|
"zoom": c.zoom,
|
||||||
|
"center_lat": c.center_lat,
|
||||||
|
"center_lon": c.center_lon,
|
||||||
|
"size_meters": c.size_meters,
|
||||||
|
"terminal_status": c.terminal_status,
|
||||||
|
"tiles_downloaded": c.tiles_downloaded,
|
||||||
|
"tiles_reused": c.tiles_reused,
|
||||||
|
"csv_path": c.csv_path,
|
||||||
|
"summary_path": c.summary_path,
|
||||||
|
}
|
||||||
|
for c in chunks
|
||||||
|
],
|
||||||
|
"totals": {
|
||||||
|
"regions": len(chunks),
|
||||||
|
"tiles_downloaded": total_downloaded,
|
||||||
|
"tiles_reused": total_reused,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
args.output_summary.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
args.output_summary.write_text(json.dumps(summary, indent=2))
|
||||||
|
print(f"[done] summary written to {args.output_summary}")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
+404
@@ -0,0 +1,404 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Seed a tlog-derived route via satellite-provider's Route API (AZ-838).
|
||||||
|
|
||||||
|
Second deliverable of Epic AZ-835 (C2). Reads a Mavlink ``.tlog`` file,
|
||||||
|
extracts a route via AZ-836's :func:`extract_route_from_tlog`, then
|
||||||
|
hands the resulting :class:`RouteSpec` to
|
||||||
|
:class:`SatelliteProviderRouteClient` which:
|
||||||
|
|
||||||
|
1. Pre-emptively validates the request body against AZ-809 rules.
|
||||||
|
2. POSTs ``/api/satellite/route`` with ``requestMaps=true``.
|
||||||
|
3. Polls ``GET /api/satellite/route/{id}`` until ``mapsReady=true`` or
|
||||||
|
a terminal failure status.
|
||||||
|
4. Verifies coverage via ``POST /api/satellite/tiles/inventory``.
|
||||||
|
|
||||||
|
This script is intended to run from the gps-denied-onboard repo root
|
||||||
|
against a running ``satellite-provider`` (typically the Jetson e2e
|
||||||
|
harness's service). It does NOT spin up the service itself and does
|
||||||
|
NOT modify any satellite-provider code or configuration.
|
||||||
|
|
||||||
|
Required environment (loaded from ``.env.test`` if not exported)::
|
||||||
|
|
||||||
|
SATELLITE_PROVIDER_URL e.g. https://satellite-provider:8080
|
||||||
|
SATELLITE_PROVIDER_API_KEY a valid HS256 JWT (mint with scripts/mint_dev_jwt.py)
|
||||||
|
SATELLITE_PROVIDER_TLS_INSECURE optional, "1" to accept self-signed dev certs
|
||||||
|
JWT_SECRET / JWT_ISSUER / JWT_AUDIENCE required only if --auto-mint-jwt is passed
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
# mint a JWT then seed using a Derkachi tlog
|
||||||
|
export SATELLITE_PROVIDER_API_KEY="$(python scripts/mint_dev_jwt.py)"
|
||||||
|
python tests/fixtures/derkachi_c6/seed_route.py \
|
||||||
|
--tlog tests/fixtures/derkachi_c6/derkachi.tlog
|
||||||
|
|
||||||
|
# dry-run: extract route, print planned payload + sha256, no HTTP
|
||||||
|
python tests/fixtures/derkachi_c6/seed_route.py \
|
||||||
|
--tlog tests/fixtures/derkachi_c6/derkachi.tlog --dry-run
|
||||||
|
|
||||||
|
# write a JSON summary for downstream consumers (CI / fixture)
|
||||||
|
python tests/fixtures/derkachi_c6/seed_route.py \
|
||||||
|
--tlog tests/fixtures/derkachi_c6/derkachi.tlog \
|
||||||
|
--output-summary /tmp/route_seed.json
|
||||||
|
|
||||||
|
Exit codes::
|
||||||
|
|
||||||
|
0 route reached mapsReady=true and inventory verification passed
|
||||||
|
71 config malformed (tlog unreadable / no waypoints extracted)
|
||||||
|
72 required env var missing
|
||||||
|
73 satellite-provider unreachable (network / TLS error)
|
||||||
|
74 route request rejected (HTTP 4xx + ProblemDetails)
|
||||||
|
75 HTTP 5xx OR route terminal failure (mapsReady never reached)
|
||||||
|
76 inventory verification mismatch (zero tiles present)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
import httpx # noqa: F401 -- imported eagerly so missing-dep exits 72
|
||||||
|
except ImportError as exc:
|
||||||
|
sys.stderr.write(
|
||||||
|
f"ERROR: httpx not installed: {exc}\n"
|
||||||
|
"Run `pip install -e .[dev]` from the repo root.\n"
|
||||||
|
)
|
||||||
|
sys.exit(72)
|
||||||
|
|
||||||
|
from gps_denied_onboard.components.c11_tile_manager.errors import (
|
||||||
|
RouteTerminalFailureError,
|
||||||
|
RouteTransientError,
|
||||||
|
RouteValidationError,
|
||||||
|
)
|
||||||
|
from gps_denied_onboard.components.c11_tile_manager.route_client import (
|
||||||
|
SatelliteProviderRouteClient,
|
||||||
|
)
|
||||||
|
from gps_denied_onboard.replay_input.tlog_route import extract_route_from_tlog
|
||||||
|
|
||||||
|
|
||||||
|
_DEFAULT_MAX_WAYPOINTS = 10
|
||||||
|
_DEFAULT_REGION_SIZE_M = 500.0
|
||||||
|
_DEFAULT_ZOOM_LEVEL = 18
|
||||||
|
_DEFAULT_TLOG = Path("tests/fixtures/derkachi_c6/derkachi.tlog")
|
||||||
|
_MIN_INVENTORY_PRESENT = 1
|
||||||
|
|
||||||
|
|
||||||
|
def _load_env_file(path: Path) -> dict[str, str]:
|
||||||
|
"""Parse a KEY=VALUE env file. Honours quoting; ignores comments."""
|
||||||
|
|
||||||
|
if not path.is_file():
|
||||||
|
return {}
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
for raw in path.read_text("utf-8").splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
out[key.strip()] = value.strip().strip('"').strip("'")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_env(name: str, env_file_values: dict[str, str]) -> str | None:
|
||||||
|
return os.environ.get(name) or env_file_values.get(name)
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_mint_jwt() -> str | None:
|
||||||
|
"""Run ``scripts/mint_dev_jwt.py`` and return the printed JWT.
|
||||||
|
|
||||||
|
Mirrors what an operator would do manually:
|
||||||
|
``export SATELLITE_PROVIDER_API_KEY=$(python scripts/mint_dev_jwt.py)``.
|
||||||
|
Returns ``None`` if the script fails or is missing — the caller
|
||||||
|
surfaces that as exit 72 (missing env).
|
||||||
|
"""
|
||||||
|
|
||||||
|
script = Path("scripts/mint_dev_jwt.py")
|
||||||
|
if not script.is_file():
|
||||||
|
sys.stderr.write(
|
||||||
|
f"ERROR: --auto-mint-jwt requested but {script} not found.\n"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, str(script)],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
sys.stderr.write(f"ERROR: --auto-mint-jwt failed to launch: {exc}\n")
|
||||||
|
return None
|
||||||
|
if result.returncode != 0:
|
||||||
|
sys.stderr.write(
|
||||||
|
f"ERROR: --auto-mint-jwt exited with rc={result.returncode}; "
|
||||||
|
f"stderr={result.stderr.strip()!r}\n"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
token = result.stdout.strip()
|
||||||
|
if not token:
|
||||||
|
sys.stderr.write("ERROR: --auto-mint-jwt produced empty output.\n")
|
||||||
|
return None
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"--tlog",
|
||||||
|
type=Path,
|
||||||
|
default=_DEFAULT_TLOG,
|
||||||
|
help=(
|
||||||
|
f"Path to the .tlog file to extract a route from "
|
||||||
|
f"(default: {_DEFAULT_TLOG})."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--max-waypoints",
|
||||||
|
type=int,
|
||||||
|
default=_DEFAULT_MAX_WAYPOINTS,
|
||||||
|
help=(
|
||||||
|
f"Maximum waypoints to keep after Douglas-Peucker decimation "
|
||||||
|
f"(default: {_DEFAULT_MAX_WAYPOINTS}, see AZ-836)."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--region-size-meters",
|
||||||
|
type=float,
|
||||||
|
default=_DEFAULT_REGION_SIZE_M,
|
||||||
|
help=(
|
||||||
|
f"Per-waypoint region size in metres "
|
||||||
|
f"(default: {_DEFAULT_REGION_SIZE_M}; AZ-809 range "
|
||||||
|
f"[100, 10000])."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--zoom-level",
|
||||||
|
type=int,
|
||||||
|
default=_DEFAULT_ZOOM_LEVEL,
|
||||||
|
help=(
|
||||||
|
f"Web-Mercator zoom for the route "
|
||||||
|
f"(default: {_DEFAULT_ZOOM_LEVEL}; AZ-809 range [0, 22])."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--name",
|
||||||
|
type=str,
|
||||||
|
default=None,
|
||||||
|
help=(
|
||||||
|
"Optional human-readable name. Default: "
|
||||||
|
"<tlog-stem>-<short-hash> (deterministic per RouteSpec)."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--description",
|
||||||
|
type=str,
|
||||||
|
default=None,
|
||||||
|
help="Optional free-text description (max 1000 chars per AZ-809).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--env-file",
|
||||||
|
type=Path,
|
||||||
|
default=Path(".env.test"),
|
||||||
|
help="Fallback env file (default: .env.test in CWD).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output-summary",
|
||||||
|
type=Path,
|
||||||
|
default=None,
|
||||||
|
help="Optional path to write a JSON summary of the seeding run.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help=(
|
||||||
|
"Extract route and print planned payload + sha256 without "
|
||||||
|
"submitting any HTTP request."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--auto-mint-jwt",
|
||||||
|
action="store_true",
|
||||||
|
help=(
|
||||||
|
"Run scripts/mint_dev_jwt.py to mint a fresh JWT instead of "
|
||||||
|
"reading SATELLITE_PROVIDER_API_KEY from env."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not args.tlog.is_file():
|
||||||
|
sys.stderr.write(f"ERROR: tlog not found: {args.tlog}\n")
|
||||||
|
return 71
|
||||||
|
|
||||||
|
try:
|
||||||
|
spec = extract_route_from_tlog(
|
||||||
|
args.tlog,
|
||||||
|
max_waypoints=args.max_waypoints,
|
||||||
|
)
|
||||||
|
except (FileNotFoundError, ValueError, RuntimeError) as exc:
|
||||||
|
sys.stderr.write(
|
||||||
|
f"ERROR: failed to extract route from {args.tlog}: {exc}\n"
|
||||||
|
)
|
||||||
|
return 71
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"[plan] tlog: {args.tlog}\n"
|
||||||
|
f"[plan] waypoints (after decimation): {len(spec.waypoints)}\n"
|
||||||
|
f"[plan] region_size_meters: {args.region_size_meters}\n"
|
||||||
|
f"[plan] zoom_level: {args.zoom_level}\n"
|
||||||
|
f"[plan] suggested_region_size_meters (from RouteSpec): "
|
||||||
|
f"{spec.suggested_region_size_meters}"
|
||||||
|
)
|
||||||
|
|
||||||
|
env_file_values = _load_env_file(args.env_file)
|
||||||
|
sp_url = _resolve_env("SATELLITE_PROVIDER_URL", env_file_values)
|
||||||
|
tls_insecure = (
|
||||||
|
_resolve_env("SATELLITE_PROVIDER_TLS_INSECURE", env_file_values) == "1"
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.auto_mint_jwt:
|
||||||
|
jwt_token = _auto_mint_jwt()
|
||||||
|
else:
|
||||||
|
jwt_token = _resolve_env("SATELLITE_PROVIDER_API_KEY", env_file_values)
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
# Build the planned body with a placeholder URL/JWT — the
|
||||||
|
# client never makes an HTTP call in dry-run mode but does
|
||||||
|
# run pre-emptive validation, so an OOR field still surfaces.
|
||||||
|
client = SatelliteProviderRouteClient(
|
||||||
|
base_url=sp_url or "https://placeholder.invalid",
|
||||||
|
jwt=jwt_token or "placeholder",
|
||||||
|
tls_insecure=tls_insecure,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
body, sha256 = client.build_planned_payload(
|
||||||
|
spec,
|
||||||
|
name=args.name,
|
||||||
|
region_size_meters=args.region_size_meters,
|
||||||
|
zoom_level=args.zoom_level,
|
||||||
|
description=args.description,
|
||||||
|
)
|
||||||
|
except RouteValidationError as exc:
|
||||||
|
sys.stderr.write(
|
||||||
|
f"ERROR: pre-emptive validation rejected request: {exc}\n"
|
||||||
|
f" field_errors={json.dumps(exc.field_errors, indent=2)}\n"
|
||||||
|
)
|
||||||
|
return 74
|
||||||
|
print("\n[dry-run] planned payload:")
|
||||||
|
print(json.dumps(body, indent=2, sort_keys=True))
|
||||||
|
print(f"\n[dry-run] payload sha256: {sha256}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if not sp_url:
|
||||||
|
sys.stderr.write("ERROR: SATELLITE_PROVIDER_URL not set (env or .env.test).\n")
|
||||||
|
return 72
|
||||||
|
if not jwt_token:
|
||||||
|
sys.stderr.write(
|
||||||
|
"ERROR: SATELLITE_PROVIDER_API_KEY not set. Mint with:\n"
|
||||||
|
" python scripts/mint_dev_jwt.py\n"
|
||||||
|
"or pass --auto-mint-jwt.\n"
|
||||||
|
)
|
||||||
|
return 72
|
||||||
|
|
||||||
|
client = SatelliteProviderRouteClient(
|
||||||
|
base_url=sp_url,
|
||||||
|
jwt=jwt_token,
|
||||||
|
tls_insecure=tls_insecure,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"\n[submit] satellite-provider: {sp_url} "
|
||||||
|
f"(tls_insecure={tls_insecure})"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = client.seed_route(
|
||||||
|
spec,
|
||||||
|
name=args.name,
|
||||||
|
region_size_meters=args.region_size_meters,
|
||||||
|
zoom_level=args.zoom_level,
|
||||||
|
description=args.description,
|
||||||
|
)
|
||||||
|
except RouteValidationError as exc:
|
||||||
|
sys.stderr.write(
|
||||||
|
f"ERROR: route POST rejected (4xx): {exc}\n"
|
||||||
|
f" http_status={exc.http_status}\n"
|
||||||
|
f" field_errors={json.dumps(exc.field_errors, indent=2)}\n"
|
||||||
|
)
|
||||||
|
return 74
|
||||||
|
except RouteTransientError as exc:
|
||||||
|
cause = exc.__cause__
|
||||||
|
sys.stderr.write(
|
||||||
|
f"ERROR: satellite-provider transient failure: {exc}\n"
|
||||||
|
)
|
||||||
|
if cause is not None:
|
||||||
|
sys.stderr.write(f" cause: {type(cause).__name__}: {cause}\n")
|
||||||
|
# Distinguish unreachable (no cause / connection-level) from
|
||||||
|
# 5xx so operators can read the exit code without parsing logs.
|
||||||
|
if cause is not None and isinstance(
|
||||||
|
cause, (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout)
|
||||||
|
):
|
||||||
|
return 73
|
||||||
|
return 75
|
||||||
|
except RouteTerminalFailureError as exc:
|
||||||
|
sys.stderr.write(
|
||||||
|
f"ERROR: route did not complete: {exc}\n"
|
||||||
|
f" route_id={exc.route_id}\n"
|
||||||
|
f" detail={json.dumps(exc.detail, indent=2) if exc.detail else None}\n"
|
||||||
|
)
|
||||||
|
return 75
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"\n[done] route_id={result.route_id}\n"
|
||||||
|
f"[done] terminal_status={result.terminal_status}\n"
|
||||||
|
f"[done] maps_ready={result.maps_ready}\n"
|
||||||
|
f"[done] tile_count={result.tile_count}\n"
|
||||||
|
f"[done] elapsed_ms={result.elapsed_ms}\n"
|
||||||
|
f"[done] payload_sha256={result.submitted_payload_sha256}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.tile_count < _MIN_INVENTORY_PRESENT:
|
||||||
|
sys.stderr.write(
|
||||||
|
"ERROR: inventory verification reported zero tiles present "
|
||||||
|
"for the route's coverage. The server reached mapsReady=true "
|
||||||
|
"but the inventory call returned no matches — this typically "
|
||||||
|
"indicates a coverage / projection mismatch and warrants "
|
||||||
|
"investigation.\n"
|
||||||
|
)
|
||||||
|
return 76
|
||||||
|
|
||||||
|
if args.output_summary:
|
||||||
|
summary = {
|
||||||
|
"tlog": str(args.tlog),
|
||||||
|
"sp_url": sp_url,
|
||||||
|
"tls_insecure": tls_insecure,
|
||||||
|
"spec": {
|
||||||
|
"waypoint_count": len(spec.waypoints),
|
||||||
|
"suggested_region_size_meters": spec.suggested_region_size_meters,
|
||||||
|
"source_tlog": str(spec.source_tlog) if spec.source_tlog else None,
|
||||||
|
},
|
||||||
|
"request": {
|
||||||
|
"name": args.name,
|
||||||
|
"region_size_meters": args.region_size_meters,
|
||||||
|
"zoom_level": args.zoom_level,
|
||||||
|
"description": args.description,
|
||||||
|
},
|
||||||
|
"result": {
|
||||||
|
"route_id": str(result.route_id),
|
||||||
|
"terminal_status": result.terminal_status,
|
||||||
|
"maps_ready": result.maps_ready,
|
||||||
|
"tile_count": result.tile_count,
|
||||||
|
"elapsed_ms": result.elapsed_ms,
|
||||||
|
"submitted_payload_sha256": result.submitted_payload_sha256,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
args.output_summary.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
args.output_summary.write_text(json.dumps(summary, indent=2))
|
||||||
|
print(f"[done] summary written to {args.output_summary}")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""AZ-838 Route client integration test (AC-8, AC-10).
|
||||||
|
|
||||||
|
Gated on ``RUN_E2E=1`` AND ``SATELLITE_PROVIDER_URL`` AND
|
||||||
|
``SATELLITE_PROVIDER_API_KEY`` AND ``DERKACHI_TLOG`` per the AZ-838
|
||||||
|
spec. Without those, the test SKIPs with an explicit reason — same
|
||||||
|
pattern as AZ-404's ``RUN_REPLAY_E2E`` gate. The intent is that AC-8
|
||||||
|
and AC-10 have a concrete pytest entry point so they can be exercised
|
||||||
|
on the Jetson harness without re-discovering wiring.
|
||||||
|
|
||||||
|
The test is intentionally minimal: it verifies that when a real
|
||||||
|
``satellite-provider`` is reachable, a Derkachi tlog round-trips
|
||||||
|
through :class:`SatelliteProviderRouteClient.seed_route` and reports
|
||||||
|
``maps_ready=True`` with a non-zero ``tile_count``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from gps_denied_onboard.components.c11_tile_manager.route_client import (
|
||||||
|
SatelliteProviderRouteClient,
|
||||||
|
)
|
||||||
|
from gps_denied_onboard.replay_input.tlog_route import extract_route_from_tlog
|
||||||
|
|
||||||
|
|
||||||
|
_RUN_E2E = os.getenv("RUN_E2E") == "1"
|
||||||
|
_SP_URL = os.getenv("SATELLITE_PROVIDER_URL")
|
||||||
|
_SP_JWT = os.getenv("SATELLITE_PROVIDER_API_KEY")
|
||||||
|
_TLS_INSECURE = os.getenv("SATELLITE_PROVIDER_TLS_INSECURE") == "1"
|
||||||
|
_DERKACHI_TLOG = os.getenv("DERKACHI_TLOG")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
not (_RUN_E2E and _SP_URL and _SP_JWT and _DERKACHI_TLOG),
|
||||||
|
reason=(
|
||||||
|
"AZ-838 AC-8/AC-10 require RUN_E2E=1 + SATELLITE_PROVIDER_URL + "
|
||||||
|
"SATELLITE_PROVIDER_API_KEY + DERKACHI_TLOG (path to derkachi.tlog) "
|
||||||
|
"— typically run on the Jetson e2e harness."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_seed_route_against_live_sp_with_derkachi_tlog() -> None:
|
||||||
|
# Arrange
|
||||||
|
tlog_path = Path(_DERKACHI_TLOG) # type: ignore[arg-type]
|
||||||
|
assert tlog_path.is_file(), f"derkachi tlog not found: {tlog_path}"
|
||||||
|
spec = extract_route_from_tlog(tlog_path, max_waypoints=10)
|
||||||
|
client = SatelliteProviderRouteClient(
|
||||||
|
base_url=_SP_URL, # type: ignore[arg-type]
|
||||||
|
jwt=_SP_JWT, # type: ignore[arg-type]
|
||||||
|
tls_insecure=_TLS_INSECURE,
|
||||||
|
poll_interval_s=5.0,
|
||||||
|
poll_max_attempts=24,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = client.seed_route(
|
||||||
|
spec,
|
||||||
|
name=f"az838-derkachi-{tlog_path.stem}",
|
||||||
|
region_size_meters=500.0,
|
||||||
|
zoom_level=18,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result.maps_ready is True
|
||||||
|
assert result.terminal_status == "completed"
|
||||||
|
assert result.tile_count > 0
|
||||||
@@ -0,0 +1,701 @@
|
|||||||
|
"""AZ-838 ``SatelliteProviderRouteClient`` unit tests (Epic AZ-835 C2).
|
||||||
|
|
||||||
|
Covers AC-1..AC-9 of
|
||||||
|
``_docs/02_tasks/todo/AZ-838_satellite_provider_route_client.md``:
|
||||||
|
|
||||||
|
* AC-1 wire shape — ``id`` / ``name`` / ``regionSizeMeters`` /
|
||||||
|
``zoomLevel`` / ``points[].lat`` / ``points[].lon`` /
|
||||||
|
``requestMaps`` / ``createTilesZip``.
|
||||||
|
* AC-2 polling — happy path + budget exhaustion.
|
||||||
|
* AC-3 4xx + RFC 7807 ProblemDetails → ``RouteValidationError``.
|
||||||
|
* AC-4 5xx / network / timeout → ``RouteTransientError``.
|
||||||
|
* AC-5 terminal failure → ``RouteTerminalFailureError``.
|
||||||
|
* AC-6 pre-emptive validation — every per-field rule mirrored from
|
||||||
|
``CreateRouteRequestValidator.cs`` (the spec's ``points <= 100`` /
|
||||||
|
``zoomLevel in 15..18`` were narrower than the actual server
|
||||||
|
validator; the client tracks the SERVER bounds so it doesn't
|
||||||
|
reject inputs the server would accept — see status-summary note
|
||||||
|
in batch 107 cycle 3 report).
|
||||||
|
* AC-7 dry-run / ``build_planned_payload`` — assembles body + sha256
|
||||||
|
without HTTP.
|
||||||
|
|
||||||
|
Tests use :class:`httpx.MockTransport` for deterministic HTTP, list-
|
||||||
|
backed log handlers for log capture, and a fake ``sleep`` so the
|
||||||
|
poll loop runs in O(0) wall time. The integration test (AC-10) is
|
||||||
|
gated on ``RUN_E2E=1`` and lives outside this file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from gps_denied_onboard.components.c11_tile_manager.errors import (
|
||||||
|
RouteTerminalFailureError,
|
||||||
|
RouteTransientError,
|
||||||
|
RouteValidationError,
|
||||||
|
SatelliteProviderRouteError,
|
||||||
|
)
|
||||||
|
from gps_denied_onboard.components.c11_tile_manager.route_client import (
|
||||||
|
RouteSeedResult,
|
||||||
|
SatelliteProviderRouteClient,
|
||||||
|
)
|
||||||
|
from gps_denied_onboard.replay_input.tlog_route import RouteSpec
|
||||||
|
|
||||||
|
_BASE_URL = "https://parent-suite.test"
|
||||||
|
_JWT = "test-jwt-az838"
|
||||||
|
_ROUTE_CREATE_PATH = "/api/satellite/route"
|
||||||
|
_ROUTE_STATUS_PATH_PREFIX = "/api/satellite/route/"
|
||||||
|
_INVENTORY_PATH = "/api/satellite/tiles/inventory"
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_spec(
|
||||||
|
waypoints: tuple[tuple[float, float], ...] | None = None,
|
||||||
|
*,
|
||||||
|
region_size: float = 500.0,
|
||||||
|
source: Path | None = None,
|
||||||
|
) -> RouteSpec:
|
||||||
|
return RouteSpec(
|
||||||
|
waypoints=waypoints
|
||||||
|
or (
|
||||||
|
(49.5731, 36.4456),
|
||||||
|
(49.5750, 36.4470),
|
||||||
|
(49.5770, 36.4490),
|
||||||
|
),
|
||||||
|
suggested_region_size_meters=region_size,
|
||||||
|
source_tlog=source or Path("tests/fixtures/derkachi_c6/derkachi.tlog"),
|
||||||
|
source_segment=(0, 99),
|
||||||
|
total_distance_meters=2500.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_log_capture() -> tuple[logging.Logger, list[logging.LogRecord]]:
|
||||||
|
records: list[logging.LogRecord] = []
|
||||||
|
|
||||||
|
class _Handler(logging.Handler):
|
||||||
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
|
records.append(record)
|
||||||
|
|
||||||
|
logger = logging.getLogger(f"test_az838_{id(records)}")
|
||||||
|
logger.handlers.clear()
|
||||||
|
logger.addHandler(_Handler())
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
logger.propagate = False
|
||||||
|
return logger, records
|
||||||
|
|
||||||
|
|
||||||
|
def _build_client(
|
||||||
|
transport: httpx.MockTransport,
|
||||||
|
*,
|
||||||
|
poll_max_attempts: int = 5,
|
||||||
|
poll_interval_s: float = 0.0001,
|
||||||
|
sleeps: list[float] | None = None,
|
||||||
|
logger: logging.Logger | None = None,
|
||||||
|
) -> tuple[SatelliteProviderRouteClient, httpx.Client]:
|
||||||
|
http_client = httpx.Client(transport=transport, base_url=_BASE_URL)
|
||||||
|
sleeps_target = sleeps if sleeps is not None else []
|
||||||
|
client = SatelliteProviderRouteClient(
|
||||||
|
base_url=_BASE_URL,
|
||||||
|
jwt=_JWT,
|
||||||
|
request_timeout_s=5.0,
|
||||||
|
poll_interval_s=poll_interval_s,
|
||||||
|
poll_max_attempts=poll_max_attempts,
|
||||||
|
http_client=http_client,
|
||||||
|
sleep=sleeps_target.append,
|
||||||
|
logger=logger,
|
||||||
|
)
|
||||||
|
return client, http_client
|
||||||
|
|
||||||
|
|
||||||
|
def _route_status_url(route_id: uuid.UUID) -> str:
|
||||||
|
return f"{_BASE_URL}{_ROUTE_STATUS_PATH_PREFIX}{route_id}"
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# Happy path (AC-1, AC-2 happy)
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_route_happy_path_posts_canonical_wire_shape() -> None:
|
||||||
|
# Arrange
|
||||||
|
spec = _make_spec()
|
||||||
|
captured_post: dict = {}
|
||||||
|
captured_inventory: dict = {}
|
||||||
|
poll_calls: list[str] = []
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
if request.method == "POST" and request.url.path == _ROUTE_CREATE_PATH:
|
||||||
|
captured_post["headers"] = dict(request.headers)
|
||||||
|
captured_post["body"] = json.loads(request.content)
|
||||||
|
return httpx.Response(200, json={"status": "submitted"})
|
||||||
|
if (
|
||||||
|
request.method == "GET"
|
||||||
|
and request.url.path.startswith(_ROUTE_STATUS_PATH_PREFIX)
|
||||||
|
):
|
||||||
|
poll_calls.append(request.url.path)
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"status": "completed", "mapsReady": True},
|
||||||
|
)
|
||||||
|
if request.method == "POST" and request.url.path == _INVENTORY_PATH:
|
||||||
|
captured_inventory["body"] = json.loads(request.content)
|
||||||
|
tiles = json.loads(request.content)["tiles"]
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"results": [
|
||||||
|
{**t, "present": True, "etag": "abc"}
|
||||||
|
for t in tiles
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return httpx.Response(404)
|
||||||
|
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
client, http_client = _build_client(transport)
|
||||||
|
try:
|
||||||
|
# Act
|
||||||
|
result = client.seed_route(spec, name="route-test", zoom_level=18)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
body = captured_post["body"]
|
||||||
|
assert uuid.UUID(body["id"]).int != 0
|
||||||
|
assert body["name"] == "route-test"
|
||||||
|
assert body["regionSizeMeters"] == 500.0
|
||||||
|
assert body["zoomLevel"] == 18
|
||||||
|
assert body["requestMaps"] is True
|
||||||
|
assert body["createTilesZip"] is False
|
||||||
|
assert body["points"] == [
|
||||||
|
{"lat": 49.5731, "lon": 36.4456},
|
||||||
|
{"lat": 49.5750, "lon": 36.4470},
|
||||||
|
{"lat": 49.5770, "lon": 36.4490},
|
||||||
|
]
|
||||||
|
assert captured_post["headers"]["authorization"] == f"Bearer {_JWT}"
|
||||||
|
|
||||||
|
assert isinstance(result, RouteSeedResult)
|
||||||
|
assert result.route_id == uuid.UUID(body["id"])
|
||||||
|
assert result.terminal_status == "completed"
|
||||||
|
assert result.maps_ready is True
|
||||||
|
assert result.tile_count > 0
|
||||||
|
assert result.elapsed_ms >= 0
|
||||||
|
assert len(result.submitted_payload_sha256) == 64
|
||||||
|
assert len(poll_calls) == 1
|
||||||
|
|
||||||
|
inventory_tiles = captured_inventory["body"]["tiles"]
|
||||||
|
assert all(t["z"] == 18 for t in inventory_tiles)
|
||||||
|
finally:
|
||||||
|
http_client.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# AC-2: poll budget exhaustion
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_route_polls_until_maps_ready() -> None:
|
||||||
|
# Arrange
|
||||||
|
poll_count = {"n": 0}
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
if request.method == "POST" and request.url.path == _ROUTE_CREATE_PATH:
|
||||||
|
return httpx.Response(200, json={"status": "submitted"})
|
||||||
|
if request.method == "GET":
|
||||||
|
poll_count["n"] += 1
|
||||||
|
if poll_count["n"] < 3:
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"status": "processing", "mapsReady": False},
|
||||||
|
)
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"status": "completed", "mapsReady": True},
|
||||||
|
)
|
||||||
|
if request.method == "POST" and request.url.path == _INVENTORY_PATH:
|
||||||
|
tiles = json.loads(request.content)["tiles"]
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"results": [{**t, "present": True} for t in tiles]},
|
||||||
|
)
|
||||||
|
return httpx.Response(404)
|
||||||
|
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
sleeps: list[float] = []
|
||||||
|
client, http_client = _build_client(
|
||||||
|
transport, poll_max_attempts=10, sleeps=sleeps
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
# Act
|
||||||
|
result = client.seed_route(_make_spec())
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result.maps_ready is True
|
||||||
|
assert poll_count["n"] == 3
|
||||||
|
assert len(sleeps) == 2
|
||||||
|
finally:
|
||||||
|
http_client.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_route_raises_terminal_when_budget_exhausted() -> None:
|
||||||
|
# Arrange
|
||||||
|
poll_count = {"n": 0}
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
if request.method == "POST" and request.url.path == _ROUTE_CREATE_PATH:
|
||||||
|
return httpx.Response(200, json={"status": "submitted"})
|
||||||
|
if request.method == "GET":
|
||||||
|
poll_count["n"] += 1
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"status": "processing", "mapsReady": False},
|
||||||
|
)
|
||||||
|
return httpx.Response(404)
|
||||||
|
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
client, http_client = _build_client(transport, poll_max_attempts=4)
|
||||||
|
try:
|
||||||
|
# Act + Assert
|
||||||
|
with pytest.raises(RouteTerminalFailureError) as exc_info:
|
||||||
|
client.seed_route(_make_spec())
|
||||||
|
assert poll_count["n"] == 4
|
||||||
|
assert exc_info.value.route_id is not None
|
||||||
|
finally:
|
||||||
|
http_client.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# AC-3: 4xx + RFC 7807 ProblemDetails
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_route_4xx_problem_details_to_validation_error() -> None:
|
||||||
|
# Arrange
|
||||||
|
problem_body = {
|
||||||
|
"type": "https://example.com/probs/validation",
|
||||||
|
"title": "One or more validation errors occurred.",
|
||||||
|
"status": 400,
|
||||||
|
"errors": {
|
||||||
|
"regionSizeMeters": [
|
||||||
|
"must be between 100 and 10000 meters."
|
||||||
|
],
|
||||||
|
"points[0].lat": ["must be in [-90, 90]"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(400, json=problem_body)
|
||||||
|
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
client, http_client = _build_client(transport)
|
||||||
|
try:
|
||||||
|
# Act + Assert
|
||||||
|
with pytest.raises(RouteValidationError) as exc_info:
|
||||||
|
client.seed_route(_make_spec())
|
||||||
|
assert exc_info.value.http_status == 400
|
||||||
|
assert "regionSizeMeters" in exc_info.value.field_errors
|
||||||
|
assert exc_info.value.field_errors["points[0].lat"] == [
|
||||||
|
"must be in [-90, 90]"
|
||||||
|
]
|
||||||
|
finally:
|
||||||
|
http_client.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_route_4xx_without_problem_details_still_raises_validation() -> None:
|
||||||
|
# Arrange
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(403, text="forbidden")
|
||||||
|
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
client, http_client = _build_client(transport)
|
||||||
|
try:
|
||||||
|
# Act + Assert
|
||||||
|
with pytest.raises(RouteValidationError) as exc_info:
|
||||||
|
client.seed_route(_make_spec())
|
||||||
|
assert exc_info.value.http_status == 403
|
||||||
|
assert exc_info.value.field_errors == {}
|
||||||
|
finally:
|
||||||
|
http_client.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# AC-4: 5xx / network / timeout
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_route_5xx_to_transient_error() -> None:
|
||||||
|
# Arrange
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(503, text="service unavailable")
|
||||||
|
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
client, http_client = _build_client(transport)
|
||||||
|
try:
|
||||||
|
# Act + Assert
|
||||||
|
with pytest.raises(RouteTransientError):
|
||||||
|
client.seed_route(_make_spec())
|
||||||
|
finally:
|
||||||
|
http_client.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_route_network_error_preserves_cause() -> None:
|
||||||
|
# Arrange
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
raise httpx.ConnectError("simulated TCP refused")
|
||||||
|
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
client, http_client = _build_client(transport)
|
||||||
|
try:
|
||||||
|
# Act
|
||||||
|
with pytest.raises(RouteTransientError) as exc_info:
|
||||||
|
client.seed_route(_make_spec())
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert isinstance(exc_info.value.__cause__, httpx.ConnectError)
|
||||||
|
finally:
|
||||||
|
http_client.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_route_timeout_preserves_cause() -> None:
|
||||||
|
# Arrange
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
raise httpx.ReadTimeout("simulated read timeout")
|
||||||
|
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
client, http_client = _build_client(transport)
|
||||||
|
try:
|
||||||
|
# Act + Assert
|
||||||
|
with pytest.raises(RouteTransientError) as exc_info:
|
||||||
|
client.seed_route(_make_spec())
|
||||||
|
assert isinstance(exc_info.value.__cause__, httpx.ReadTimeout)
|
||||||
|
finally:
|
||||||
|
http_client.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# AC-5: terminal failure
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_route_terminal_failure_status_raises() -> None:
|
||||||
|
# Arrange
|
||||||
|
failure_payload = {
|
||||||
|
"status": "failed",
|
||||||
|
"mapsReady": False,
|
||||||
|
"error": "tile fetch exhausted retries",
|
||||||
|
}
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
if request.method == "POST" and request.url.path == _ROUTE_CREATE_PATH:
|
||||||
|
return httpx.Response(200, json={"status": "submitted"})
|
||||||
|
if request.method == "GET":
|
||||||
|
return httpx.Response(200, json=failure_payload)
|
||||||
|
return httpx.Response(404)
|
||||||
|
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
client, http_client = _build_client(transport)
|
||||||
|
try:
|
||||||
|
# Act
|
||||||
|
with pytest.raises(RouteTerminalFailureError) as exc_info:
|
||||||
|
client.seed_route(_make_spec())
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert exc_info.value.detail == failure_payload
|
||||||
|
assert exc_info.value.route_id is not None
|
||||||
|
finally:
|
||||||
|
http_client.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# AC-6: pre-emptive validation (pre-POST)
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _build_no_http_client() -> SatelliteProviderRouteClient:
|
||||||
|
"""Build a client whose transport rejects every HTTP call.
|
||||||
|
|
||||||
|
Used to verify pre-emptive validation rejects inputs BEFORE any
|
||||||
|
HTTP request leaves the client.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
raise AssertionError(
|
||||||
|
f"unexpected HTTP request after pre-emptive validation: "
|
||||||
|
f"{request.method} {request.url}"
|
||||||
|
)
|
||||||
|
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
http_client = httpx.Client(transport=transport, base_url=_BASE_URL)
|
||||||
|
return SatelliteProviderRouteClient(
|
||||||
|
base_url=_BASE_URL,
|
||||||
|
jwt=_JWT,
|
||||||
|
http_client=http_client,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_preemptive_rejects_empty_points() -> None:
|
||||||
|
# Arrange
|
||||||
|
spec = RouteSpec(
|
||||||
|
waypoints=(),
|
||||||
|
suggested_region_size_meters=500.0,
|
||||||
|
source_tlog=Path("tlog"),
|
||||||
|
source_segment=(0, 0),
|
||||||
|
total_distance_meters=0.0,
|
||||||
|
)
|
||||||
|
client = _build_no_http_client()
|
||||||
|
|
||||||
|
# Act + Assert
|
||||||
|
with pytest.raises(RouteValidationError) as exc_info:
|
||||||
|
client.seed_route(spec)
|
||||||
|
assert "points" in exc_info.value.field_errors
|
||||||
|
|
||||||
|
|
||||||
|
def test_preemptive_rejects_too_many_points() -> None:
|
||||||
|
# Arrange — server validator caps at 500, so 501 is the trigger.
|
||||||
|
spec = RouteSpec(
|
||||||
|
waypoints=tuple(
|
||||||
|
(49.0 + i * 1e-5, 36.0 + i * 1e-5) for i in range(501)
|
||||||
|
),
|
||||||
|
suggested_region_size_meters=500.0,
|
||||||
|
source_tlog=Path("tlog"),
|
||||||
|
source_segment=(0, 500),
|
||||||
|
total_distance_meters=10.0,
|
||||||
|
)
|
||||||
|
client = _build_no_http_client()
|
||||||
|
|
||||||
|
# Act + Assert
|
||||||
|
with pytest.raises(RouteValidationError) as exc_info:
|
||||||
|
client.seed_route(spec)
|
||||||
|
assert "points" in exc_info.value.field_errors
|
||||||
|
|
||||||
|
|
||||||
|
def test_preemptive_rejects_zero_region_size() -> None:
|
||||||
|
client = _build_no_http_client()
|
||||||
|
with pytest.raises(RouteValidationError) as exc_info:
|
||||||
|
client.seed_route(_make_spec(), region_size_meters=0.0)
|
||||||
|
assert "regionSizeMeters" in exc_info.value.field_errors
|
||||||
|
|
||||||
|
|
||||||
|
def test_preemptive_rejects_oversized_region() -> None:
|
||||||
|
client = _build_no_http_client()
|
||||||
|
with pytest.raises(RouteValidationError) as exc_info:
|
||||||
|
client.seed_route(_make_spec(), region_size_meters=10_001.0)
|
||||||
|
assert "regionSizeMeters" in exc_info.value.field_errors
|
||||||
|
|
||||||
|
|
||||||
|
def test_preemptive_rejects_oor_zoom_high() -> None:
|
||||||
|
client = _build_no_http_client()
|
||||||
|
with pytest.raises(RouteValidationError) as exc_info:
|
||||||
|
client.seed_route(_make_spec(), zoom_level=23)
|
||||||
|
assert "zoomLevel" in exc_info.value.field_errors
|
||||||
|
|
||||||
|
|
||||||
|
def test_preemptive_rejects_oor_zoom_low() -> None:
|
||||||
|
client = _build_no_http_client()
|
||||||
|
with pytest.raises(RouteValidationError) as exc_info:
|
||||||
|
client.seed_route(_make_spec(), zoom_level=-1)
|
||||||
|
assert "zoomLevel" in exc_info.value.field_errors
|
||||||
|
|
||||||
|
|
||||||
|
def test_preemptive_rejects_oor_lat() -> None:
|
||||||
|
spec = _make_spec(waypoints=((100.0, 36.0), (49.0, 36.0)))
|
||||||
|
client = _build_no_http_client()
|
||||||
|
with pytest.raises(RouteValidationError) as exc_info:
|
||||||
|
client.seed_route(spec)
|
||||||
|
assert any(k.startswith("points[") for k in exc_info.value.field_errors)
|
||||||
|
|
||||||
|
|
||||||
|
def test_preemptive_rejects_oor_lon() -> None:
|
||||||
|
spec = _make_spec(waypoints=((49.0, 200.0), (49.0, 36.0)))
|
||||||
|
client = _build_no_http_client()
|
||||||
|
with pytest.raises(RouteValidationError) as exc_info:
|
||||||
|
client.seed_route(spec)
|
||||||
|
assert any(k.startswith("points[") for k in exc_info.value.field_errors)
|
||||||
|
|
||||||
|
|
||||||
|
def test_preemptive_rejects_oversized_name() -> None:
|
||||||
|
client = _build_no_http_client()
|
||||||
|
with pytest.raises(RouteValidationError) as exc_info:
|
||||||
|
client.seed_route(_make_spec(), name="x" * 201)
|
||||||
|
assert "name" in exc_info.value.field_errors
|
||||||
|
|
||||||
|
|
||||||
|
def test_preemptive_rejects_oversized_description() -> None:
|
||||||
|
client = _build_no_http_client()
|
||||||
|
with pytest.raises(RouteValidationError) as exc_info:
|
||||||
|
client.seed_route(_make_spec(), description="x" * 1001)
|
||||||
|
assert "description" in exc_info.value.field_errors
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# AC-7: dry-run / build_planned_payload
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_planned_payload_runs_without_http() -> None:
|
||||||
|
# Arrange
|
||||||
|
client = _build_no_http_client()
|
||||||
|
|
||||||
|
# Act
|
||||||
|
body, sha256 = client.build_planned_payload(
|
||||||
|
_make_spec(),
|
||||||
|
name="dry-run-test",
|
||||||
|
zoom_level=18,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert body["name"] == "dry-run-test"
|
||||||
|
assert body["regionSizeMeters"] == 500.0
|
||||||
|
assert body["zoomLevel"] == 18
|
||||||
|
assert body["requestMaps"] is True
|
||||||
|
assert body["createTilesZip"] is False
|
||||||
|
assert len(body["points"]) == 3
|
||||||
|
assert len(sha256) == 64
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_planned_payload_is_deterministic_for_same_inputs() -> None:
|
||||||
|
# Arrange — same name + same spec must produce the same sha256
|
||||||
|
# (route_id varies, so the body itself differs; the sha256 is over
|
||||||
|
# the canonical JSON, so it varies too — assert that it's stable
|
||||||
|
# WITHIN one build but distinct per call due to fresh route_id).
|
||||||
|
client = _build_no_http_client()
|
||||||
|
|
||||||
|
# Act
|
||||||
|
body_a, sha_a = client.build_planned_payload(
|
||||||
|
_make_spec(), name="same-name", zoom_level=18
|
||||||
|
)
|
||||||
|
body_b, sha_b = client.build_planned_payload(
|
||||||
|
_make_spec(), name="same-name", zoom_level=18
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert body_a["id"] != body_b["id"]
|
||||||
|
assert sha_a != sha_b
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_planned_payload_runs_validation() -> None:
|
||||||
|
# Arrange
|
||||||
|
client = _build_no_http_client()
|
||||||
|
|
||||||
|
# Act + Assert — dry-run must surface OOR zoom the same as a live run
|
||||||
|
with pytest.raises(RouteValidationError):
|
||||||
|
client.build_planned_payload(_make_spec(), zoom_level=99)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# Constructor sanity
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_constructor_rejects_empty_base_url() -> None:
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
SatelliteProviderRouteClient(base_url="", jwt="x")
|
||||||
|
|
||||||
|
|
||||||
|
def test_constructor_rejects_empty_jwt() -> None:
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
SatelliteProviderRouteClient(base_url="https://x", jwt="")
|
||||||
|
|
||||||
|
|
||||||
|
def test_constructor_rejects_nonpositive_timeout() -> None:
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
SatelliteProviderRouteClient(
|
||||||
|
base_url="https://x", jwt="y", request_timeout_s=0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_constructor_rejects_nonpositive_poll_interval() -> None:
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
SatelliteProviderRouteClient(
|
||||||
|
base_url="https://x", jwt="y", poll_interval_s=0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_constructor_rejects_nonpositive_poll_max_attempts() -> None:
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
SatelliteProviderRouteClient(
|
||||||
|
base_url="https://x", jwt="y", poll_max_attempts=0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# Error class hierarchy
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_error_subclass_relationships() -> None:
|
||||||
|
# Assert
|
||||||
|
assert issubclass(RouteValidationError, SatelliteProviderRouteError)
|
||||||
|
assert issubclass(RouteTransientError, SatelliteProviderRouteError)
|
||||||
|
assert issubclass(RouteTerminalFailureError, SatelliteProviderRouteError)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# Inventory edge cases
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_inventory_404_during_verify_raises_validation() -> None:
|
||||||
|
# Arrange — route POST OK, polling reports ready, inventory 404s.
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
if request.method == "POST" and request.url.path == _ROUTE_CREATE_PATH:
|
||||||
|
return httpx.Response(200, json={"status": "submitted"})
|
||||||
|
if request.method == "GET":
|
||||||
|
return httpx.Response(
|
||||||
|
200, json={"status": "completed", "mapsReady": True}
|
||||||
|
)
|
||||||
|
if request.method == "POST" and request.url.path == _INVENTORY_PATH:
|
||||||
|
return httpx.Response(404, text="not found")
|
||||||
|
return httpx.Response(404)
|
||||||
|
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
client, http_client = _build_client(transport)
|
||||||
|
try:
|
||||||
|
# Act + Assert
|
||||||
|
with pytest.raises(RouteValidationError):
|
||||||
|
client.seed_route(_make_spec())
|
||||||
|
finally:
|
||||||
|
http_client.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_logging_emits_structured_extra_for_submit_and_poll() -> None:
|
||||||
|
# Arrange
|
||||||
|
logger, records = _make_log_capture()
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
if request.method == "POST" and request.url.path == _ROUTE_CREATE_PATH:
|
||||||
|
return httpx.Response(200, json={"status": "submitted"})
|
||||||
|
if request.method == "GET":
|
||||||
|
return httpx.Response(
|
||||||
|
200, json={"status": "completed", "mapsReady": True}
|
||||||
|
)
|
||||||
|
if request.method == "POST" and request.url.path == _INVENTORY_PATH:
|
||||||
|
tiles = json.loads(request.content)["tiles"]
|
||||||
|
return httpx.Response(
|
||||||
|
200, json={"results": [{**t, "present": True} for t in tiles]}
|
||||||
|
)
|
||||||
|
return httpx.Response(404)
|
||||||
|
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
client, http_client = _build_client(transport, logger=logger)
|
||||||
|
try:
|
||||||
|
# Act
|
||||||
|
client.seed_route(_make_spec())
|
||||||
|
|
||||||
|
# Assert — at minimum we expect submit + one poll-tick + terminal + inventory
|
||||||
|
kinds = {getattr(r, "kind", None) for r in records}
|
||||||
|
assert "c11.route.submit" in kinds
|
||||||
|
assert "c11.route.poll.tick" in kinds
|
||||||
|
assert "c11.route.poll.terminal" in kinds
|
||||||
|
assert "c11.route.inventory" in kinds
|
||||||
|
finally:
|
||||||
|
http_client.close()
|
||||||
@@ -1,16 +1,21 @@
|
|||||||
"""AZ-316 ``HttpTileDownloader`` unit tests.
|
"""AZ-316 / AZ-777 ``HttpTileDownloader`` unit tests.
|
||||||
|
|
||||||
Covers AC-1 .. AC-12 plus the throughput NFR from
|
Covers AC-1 .. AC-12 plus the throughput NFR from
|
||||||
``_docs/02_tasks/todo/AZ-316_c11_tile_downloader.md``. Uses
|
``_docs/02_tasks/todo/AZ-316_c11_tile_downloader.md``, against the
|
||||||
:class:`httpx.MockTransport` for deterministic HTTP responses, a
|
AZ-777 contract update (POST ``/api/satellite/tiles/inventory`` for
|
||||||
list-backed log handler for log capture, and stub C6 stores so this
|
bulk lookup + GET ``/tiles/{z}/{x}/{y}`` for body download — see
|
||||||
suite never depends on AZ-303 / AZ-305 / AZ-307 / AZ-308 internals.
|
``../satellite-provider/_docs/02_document/contracts/api/tile-inventory.md``
|
||||||
|
v1.0.0). Uses :class:`httpx.MockTransport` for deterministic HTTP
|
||||||
|
responses, a list-backed log handler for log capture, and stub C6
|
||||||
|
stores so this suite never depends on AZ-303 / AZ-305 / AZ-307 /
|
||||||
|
AZ-308 internals.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -31,10 +36,15 @@ from gps_denied_onboard.components.c11_tile_manager import (
|
|||||||
request_hash,
|
request_hash,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
_BASE_URL = "https://parent-suite.test"
|
_BASE_URL = "https://parent-suite.test"
|
||||||
_LIST_PATH = "/api/satellite/tiles"
|
_INVENTORY_PATH = "/api/satellite/tiles/inventory"
|
||||||
|
_TILES_PATH_PREFIX = "/tiles/"
|
||||||
_API_KEY = "test-api-key-001"
|
_API_KEY = "test-api-key-001"
|
||||||
|
# Mirror of c11.tile_downloader._DEFAULT_ESTIMATED_TILE_BYTES (AZ-777):
|
||||||
|
# the inventory contract no longer returns content-length hints, so the
|
||||||
|
# AZ-308 budget pre-check reserves this constant per `present=true`
|
||||||
|
# tile. Keep in sync with the production module.
|
||||||
|
_DEFAULT_ESTIMATED_TILE_BYTES = 50_000
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
@@ -55,16 +65,22 @@ _StubFreshnessRejection.__name__ = "FreshnessRejectionError"
|
|||||||
|
|
||||||
|
|
||||||
class _StubTileWriter:
|
class _StubTileWriter:
|
||||||
"""Captures `write_tile_for_download` calls + scripts the freshness label."""
|
"""Captures `write_tile_for_download` calls + scripts the freshness label.
|
||||||
|
|
||||||
|
AZ-777: keyed by *call index* rather than `(z, lat, lon)` strings.
|
||||||
|
The downloader now derives (lat, lon) from the slippy-map coord, so
|
||||||
|
tests can no longer fabricate arbitrary lat/lons in their fixtures;
|
||||||
|
the call-order is the contract.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
labels: dict[str, str] | None = None,
|
labels_by_index: dict[int, str] | None = None,
|
||||||
rejected: set[str] | None = None,
|
rejected_indices: set[int] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.labels = labels or {}
|
self.labels_by_index = labels_by_index or {}
|
||||||
self.rejected = rejected or set()
|
self.rejected_indices = rejected_indices or set()
|
||||||
self.write_calls: list[dict[str, Any]] = []
|
self.write_calls: list[dict[str, Any]] = []
|
||||||
self.exists_calls: list[tuple[int, float, float]] = []
|
self.exists_calls: list[tuple[int, float, float]] = []
|
||||||
|
|
||||||
@@ -81,22 +97,23 @@ class _StubTileWriter:
|
|||||||
content_sha256_hex: str,
|
content_sha256_hex: str,
|
||||||
sector_class: str,
|
sector_class: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
tid = _tid(zoom_level, lat, lon)
|
call_index = len(self.write_calls)
|
||||||
self.write_calls.append(
|
self.write_calls.append(
|
||||||
{
|
{
|
||||||
"tile_id": tid,
|
"call_index": call_index,
|
||||||
|
"zoom_level": zoom_level,
|
||||||
|
"lat": lat,
|
||||||
|
"lon": lon,
|
||||||
"tile_blob_len": len(tile_blob),
|
"tile_blob_len": len(tile_blob),
|
||||||
"content_sha256_hex": content_sha256_hex,
|
"content_sha256_hex": content_sha256_hex,
|
||||||
"sector_class": sector_class,
|
"sector_class": sector_class,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if tid in self.rejected:
|
if call_index in self.rejected_indices:
|
||||||
raise _StubFreshnessRejection(f"freshness rejected {tid}")
|
raise _StubFreshnessRejection(f"freshness rejected call_index={call_index}")
|
||||||
return self.labels.get(tid, "fresh")
|
return self.labels_by_index.get(call_index, "fresh")
|
||||||
|
|
||||||
def tile_already_present(
|
def tile_already_present(self, *, zoom_level: int, lat: float, lon: float) -> bool:
|
||||||
self, *, zoom_level: int, lat: float, lon: float
|
|
||||||
) -> bool:
|
|
||||||
self.exists_calls.append((zoom_level, lat, lon))
|
self.exists_calls.append((zoom_level, lat, lon))
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -115,10 +132,6 @@ class _StubBudgetEnforcer:
|
|||||||
return object()
|
return object()
|
||||||
|
|
||||||
|
|
||||||
def _tid(zoom: int, lat: float, lon: float) -> str:
|
|
||||||
return f"z{int(zoom)}_{float(lat):.6f}_{float(lon):.6f}"
|
|
||||||
|
|
||||||
|
|
||||||
def _build_downloader(
|
def _build_downloader(
|
||||||
*,
|
*,
|
||||||
transport: httpx.MockTransport,
|
transport: httpx.MockTransport,
|
||||||
@@ -172,71 +185,135 @@ def _build_downloader(
|
|||||||
return downloader, log_records, writer, enforcer, sleeps
|
return downloader, log_records, writer, enforcer, sleeps
|
||||||
|
|
||||||
|
|
||||||
|
_DEFAULT_TEST_BBOX = (45.0, -122.5, 45.5, -122.0)
|
||||||
|
|
||||||
|
|
||||||
def _make_request(
|
def _make_request(
|
||||||
*,
|
*,
|
||||||
flight_id: Any | None = None,
|
flight_id: Any | None = None,
|
||||||
cache_root: Path,
|
cache_root: Path,
|
||||||
zoom_levels: tuple[int, ...] = (14,),
|
zoom_levels: tuple[int, ...] = (14,),
|
||||||
|
bbox: tuple[float, float, float, float] = _DEFAULT_TEST_BBOX,
|
||||||
) -> DownloadRequest:
|
) -> DownloadRequest:
|
||||||
return DownloadRequest(
|
return DownloadRequest(
|
||||||
flight_id=flight_id or uuid4(),
|
flight_id=flight_id or uuid4(),
|
||||||
bbox_min_lat=45.0,
|
bbox_min_lat=bbox[0],
|
||||||
bbox_min_lon=-122.5,
|
bbox_min_lon=bbox[1],
|
||||||
bbox_max_lat=45.5,
|
bbox_max_lat=bbox[2],
|
||||||
bbox_max_lon=-122.0,
|
bbox_max_lon=bbox[3],
|
||||||
zoom_levels=zoom_levels,
|
zoom_levels=zoom_levels,
|
||||||
sector_class=SectorClassification.STABLE_REAR,
|
sector_class=SectorClassification.STABLE_REAR,
|
||||||
cache_root=cache_root,
|
cache_root=cache_root,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _list_response(
|
def _tile_center_latlon_from_zxy(zoom: int, x: int, y: int) -> tuple[float, float]:
|
||||||
tiles: list[dict[str, Any]] | None = None,
|
"""Mirror of c11.tile_downloader._tile_center_latlon for test assertions.
|
||||||
) -> httpx.Response:
|
|
||||||
return httpx.Response(200, json={"tiles": tiles or []})
|
Both sides compute lat/lon from the slippy-map (z, x, y) tuple, so
|
||||||
|
stub freshness/label maps can be keyed on the *expected* lat/lon
|
||||||
|
without depending on the production helper at import time.
|
||||||
|
"""
|
||||||
|
n = 1 << int(zoom)
|
||||||
|
lat_n = math.degrees(math.atan(math.sinh(math.pi * (1.0 - 2.0 * int(y) / n))))
|
||||||
|
lat_s = math.degrees(math.atan(math.sinh(math.pi * (1.0 - 2.0 * (int(y) + 1) / n))))
|
||||||
|
lon_w = int(x) / n * 360.0 - 180.0
|
||||||
|
lon_e = (int(x) + 1) / n * 360.0 - 180.0
|
||||||
|
return (lat_n + lat_s) / 2.0, (lon_w + lon_e) / 2.0
|
||||||
|
|
||||||
|
|
||||||
def _tile_entry(
|
def _inventory_entry_for_coord(
|
||||||
*,
|
*,
|
||||||
zoom: int,
|
zoom: int,
|
||||||
lat: float,
|
x: int,
|
||||||
lon: float,
|
y: int,
|
||||||
|
present: bool = True,
|
||||||
resolution_m_per_px: float = 0.5,
|
resolution_m_per_px: float = 0.5,
|
||||||
estimated_bytes: int = 4096,
|
captured_at: datetime | None = None,
|
||||||
produced_at: datetime | None = None,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
produced = produced_at or datetime(2026, 5, 13, 0, 0, 0, tzinfo=timezone.utc)
|
if not present:
|
||||||
|
return {
|
||||||
|
"z": int(zoom),
|
||||||
|
"x": int(x),
|
||||||
|
"y": int(y),
|
||||||
|
"locationHash": str(uuid4()),
|
||||||
|
"present": False,
|
||||||
|
"id": None,
|
||||||
|
"capturedAt": None,
|
||||||
|
"source": None,
|
||||||
|
"flightId": None,
|
||||||
|
"resolutionMPerPx": None,
|
||||||
|
}
|
||||||
|
captured = captured_at or datetime(2026, 5, 13, 0, 0, 0, tzinfo=timezone.utc)
|
||||||
return {
|
return {
|
||||||
"tile_id": _tid(zoom, lat, lon),
|
"z": int(zoom),
|
||||||
"zoom_level": zoom,
|
"x": int(x),
|
||||||
"lat": lat,
|
"y": int(y),
|
||||||
"lon": lon,
|
"locationHash": str(uuid4()),
|
||||||
"produced_at": produced.isoformat(),
|
"present": True,
|
||||||
"resolution_m_per_px": resolution_m_per_px,
|
"id": str(uuid4()),
|
||||||
"estimated_bytes": estimated_bytes,
|
"capturedAt": captured.isoformat(),
|
||||||
"tile_size_meters": 100.0,
|
"source": "google_maps",
|
||||||
"tile_size_pixels": 256,
|
"flightId": None,
|
||||||
|
"resolutionMPerPx": float(resolution_m_per_px),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _make_route_handler(
|
def _make_inventory_handler(
|
||||||
*,
|
*,
|
||||||
list_response: httpx.Response | None = None,
|
present_count: int | None = None,
|
||||||
|
resolution_override_for_first_n: tuple[int, float] | None = None,
|
||||||
tile_response_factory: Any = None,
|
tile_response_factory: Any = None,
|
||||||
|
inventory_response_override: httpx.Response | None = None,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Route GETs by URL path: list endpoint vs per-tile endpoint."""
|
"""Route requests by method+path: POST inventory vs GET /tiles/{z}/{x}/{y}.
|
||||||
|
|
||||||
|
The inventory handler echoes the request's tile coords in
|
||||||
|
response order (per contract invariant Inv-2 / Inv-3). The
|
||||||
|
`present_count` knob marks the FIRST N entries as
|
||||||
|
``present=true`` and the rest as ``present=false``; ``None`` means
|
||||||
|
"all present". `resolution_override_for_first_n=(K, RES)` overrides
|
||||||
|
the resolution of the first K present entries (used by the AZ-316
|
||||||
|
resolution-gate test).
|
||||||
|
"""
|
||||||
|
|
||||||
def _handler(request: httpx.Request) -> httpx.Response:
|
def _handler(request: httpx.Request) -> httpx.Response:
|
||||||
path = request.url.path
|
path = request.url.path
|
||||||
is_list = (
|
method = request.method.upper()
|
||||||
path.endswith(_LIST_PATH)
|
if method == "POST" and path.endswith(_INVENTORY_PATH):
|
||||||
and request.url.params.get("list-only") == "true"
|
if inventory_response_override is not None:
|
||||||
|
return inventory_response_override
|
||||||
|
body = json.loads(request.content.decode("utf-8"))
|
||||||
|
tiles_in = body["tiles"]
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
for i, t in enumerate(tiles_in):
|
||||||
|
is_present = present_count is None or i < int(present_count)
|
||||||
|
if (
|
||||||
|
is_present
|
||||||
|
and resolution_override_for_first_n is not None
|
||||||
|
and i < int(resolution_override_for_first_n[0])
|
||||||
|
):
|
||||||
|
resolution = float(resolution_override_for_first_n[1])
|
||||||
|
else:
|
||||||
|
resolution = 0.5
|
||||||
|
results.append(
|
||||||
|
_inventory_entry_for_coord(
|
||||||
|
zoom=int(t["z"]),
|
||||||
|
x=int(t["x"]),
|
||||||
|
y=int(t["y"]),
|
||||||
|
present=is_present,
|
||||||
|
resolution_m_per_px=resolution,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return httpx.Response(200, json={"results": results})
|
||||||
|
if method == "GET" and path.startswith(_TILES_PATH_PREFIX):
|
||||||
|
if tile_response_factory is None:
|
||||||
|
return httpx.Response(200, content=b"\xff\xd8\xff\xe0fake-jpeg")
|
||||||
|
return tile_response_factory(request)
|
||||||
|
return httpx.Response(
|
||||||
|
404,
|
||||||
|
json={"detail": f"test handler: unexpected {method} {path}"},
|
||||||
)
|
)
|
||||||
if is_list:
|
|
||||||
return list_response or _list_response()
|
|
||||||
if tile_response_factory is None:
|
|
||||||
return httpx.Response(200, content=b"\xff\xd8\xff\xe0fake-jpeg")
|
|
||||||
return tile_response_factory(request)
|
|
||||||
|
|
||||||
return _handler
|
return _handler
|
||||||
|
|
||||||
@@ -247,17 +324,10 @@ def _make_route_handler(
|
|||||||
|
|
||||||
|
|
||||||
def test_ac1_100_tile_happy_path_writes_all(tmp_path: Path) -> None:
|
def test_ac1_100_tile_happy_path_writes_all(tmp_path: Path) -> None:
|
||||||
# Arrange
|
# Arrange — bbox at zoom 14 produces N coord candidates; the
|
||||||
tiles = [
|
# stub marks the first 100 as `present=true` and the rest absent.
|
||||||
_tile_entry(zoom=14, lat=45.0 + i * 0.001, lon=-122.0 - i * 0.001)
|
transport = httpx.MockTransport(_make_inventory_handler(present_count=100))
|
||||||
for i in range(100)
|
(downloader, _logs, writer, enforcer, _sleeps) = _build_downloader(transport=transport)
|
||||||
]
|
|
||||||
transport = httpx.MockTransport(
|
|
||||||
_make_route_handler(list_response=_list_response(tiles))
|
|
||||||
)
|
|
||||||
(downloader, _logs, writer, enforcer, _sleeps) = _build_downloader(
|
|
||||||
transport=transport
|
|
||||||
)
|
|
||||||
request = _make_request(cache_root=tmp_path)
|
request = _make_request(cache_root=tmp_path)
|
||||||
|
|
||||||
# Act
|
# Act
|
||||||
@@ -271,7 +341,7 @@ def test_ac1_100_tile_happy_path_writes_all(tmp_path: Path) -> None:
|
|||||||
assert report.tiles_rejected_freshness == 0
|
assert report.tiles_rejected_freshness == 0
|
||||||
assert report.tiles_downgraded == 0
|
assert report.tiles_downgraded == 0
|
||||||
assert len(writer.write_calls) == 100
|
assert len(writer.write_calls) == 100
|
||||||
assert enforcer.calls == [4096 * 100]
|
assert enforcer.calls == [_DEFAULT_ESTIMATED_TILE_BYTES * 100]
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
@@ -280,19 +350,14 @@ def test_ac1_100_tile_happy_path_writes_all(tmp_path: Path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_ac2_resolution_gate_rejects_sub_spec_tiles(tmp_path: Path) -> None:
|
def test_ac2_resolution_gate_rejects_sub_spec_tiles(tmp_path: Path) -> None:
|
||||||
# Arrange
|
# Arrange — 50 present tiles; first 10 below the 0.5 m/px floor.
|
||||||
tiles = []
|
|
||||||
for i in range(50):
|
|
||||||
res = 0.3 if i < 10 else 0.5
|
|
||||||
tiles.append(
|
|
||||||
_tile_entry(zoom=14, lat=45.0 + i * 0.001, lon=-122.0, resolution_m_per_px=res)
|
|
||||||
)
|
|
||||||
transport = httpx.MockTransport(
|
transport = httpx.MockTransport(
|
||||||
_make_route_handler(list_response=_list_response(tiles))
|
_make_inventory_handler(
|
||||||
)
|
present_count=50,
|
||||||
(downloader, log_records, writer, _enforcer, _sleeps) = _build_downloader(
|
resolution_override_for_first_n=(10, 0.3),
|
||||||
transport=transport
|
)
|
||||||
)
|
)
|
||||||
|
(downloader, log_records, writer, _enforcer, _sleeps) = _build_downloader(transport=transport)
|
||||||
|
|
||||||
# Act
|
# Act
|
||||||
report = downloader.download_tiles_for_area(_make_request(cache_root=tmp_path))
|
report = downloader.download_tiles_for_area(_make_request(cache_root=tmp_path))
|
||||||
@@ -301,7 +366,9 @@ def test_ac2_resolution_gate_rejects_sub_spec_tiles(tmp_path: Path) -> None:
|
|||||||
assert report.tiles_rejected_resolution == 10
|
assert report.tiles_rejected_resolution == 10
|
||||||
assert report.tiles_downloaded == 40
|
assert report.tiles_downloaded == 40
|
||||||
assert len(writer.write_calls) == 40
|
assert len(writer.write_calls) == 40
|
||||||
res_warnings = [r for r in log_records if getattr(r, "kind", "") == "c11.download.resolution_rejected"]
|
res_warnings = [
|
||||||
|
r for r in log_records if getattr(r, "kind", "") == "c11.download.resolution_rejected"
|
||||||
|
]
|
||||||
assert len(res_warnings) == 10
|
assert len(res_warnings) == 10
|
||||||
|
|
||||||
|
|
||||||
@@ -311,13 +378,9 @@ def test_ac2_resolution_gate_rejects_sub_spec_tiles(tmp_path: Path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_ac3_freshness_rejections_counted_and_run_continues(tmp_path: Path) -> None:
|
def test_ac3_freshness_rejections_counted_and_run_continues(tmp_path: Path) -> None:
|
||||||
# Arrange
|
# Arrange — 10 present tiles; c6 rejects the first 5 writes.
|
||||||
tiles = [_tile_entry(zoom=14, lat=45.0 + i * 0.001, lon=-122.0) for i in range(10)]
|
transport = httpx.MockTransport(_make_inventory_handler(present_count=10))
|
||||||
rejected_ids = {_tid(14, 45.0 + i * 0.001, -122.0) for i in range(5)}
|
writer = _StubTileWriter(rejected_indices={0, 1, 2, 3, 4})
|
||||||
transport = httpx.MockTransport(
|
|
||||||
_make_route_handler(list_response=_list_response(tiles))
|
|
||||||
)
|
|
||||||
writer = _StubTileWriter(rejected=rejected_ids)
|
|
||||||
(downloader, log_records, _writer, _enforcer, _sleeps) = _build_downloader(
|
(downloader, log_records, _writer, _enforcer, _sleeps) = _build_downloader(
|
||||||
transport=transport, tile_writer=writer
|
transport=transport, tile_writer=writer
|
||||||
)
|
)
|
||||||
@@ -330,7 +393,9 @@ def test_ac3_freshness_rejections_counted_and_run_continues(tmp_path: Path) -> N
|
|||||||
assert report.tiles_downloaded == 5
|
assert report.tiles_downloaded == 5
|
||||||
assert report.outcome == DownloadOutcome.SUCCESS
|
assert report.outcome == DownloadOutcome.SUCCESS
|
||||||
summary_warns = [
|
summary_warns = [
|
||||||
r for r in log_records if getattr(r, "kind", "") == "c11.download.freshness_rejected_summary"
|
r
|
||||||
|
for r in log_records
|
||||||
|
if getattr(r, "kind", "") == "c11.download.freshness_rejected_summary"
|
||||||
]
|
]
|
||||||
assert len(summary_warns) == 1
|
assert len(summary_warns) == 1
|
||||||
|
|
||||||
@@ -341,13 +406,9 @@ def test_ac3_freshness_rejections_counted_and_run_continues(tmp_path: Path) -> N
|
|||||||
|
|
||||||
|
|
||||||
def test_ac4_downgraded_label_counted_and_persisted(tmp_path: Path) -> None:
|
def test_ac4_downgraded_label_counted_and_persisted(tmp_path: Path) -> None:
|
||||||
# Arrange
|
# Arrange — 5 present tiles; c6 returns "downgraded" for the first 3 writes.
|
||||||
tiles = [_tile_entry(zoom=14, lat=45.0 + i * 0.001, lon=-122.0) for i in range(5)]
|
transport = httpx.MockTransport(_make_inventory_handler(present_count=5))
|
||||||
labels = {_tid(14, 45.0 + i * 0.001, -122.0): "downgraded" for i in range(3)}
|
writer = _StubTileWriter(labels_by_index={0: "downgraded", 1: "downgraded", 2: "downgraded"})
|
||||||
transport = httpx.MockTransport(
|
|
||||||
_make_route_handler(list_response=_list_response(tiles))
|
|
||||||
)
|
|
||||||
writer = _StubTileWriter(labels=labels)
|
|
||||||
(downloader, _logs, _writer, _enforcer, _sleeps) = _build_downloader(
|
(downloader, _logs, _writer, _enforcer, _sleeps) = _build_downloader(
|
||||||
transport=transport, tile_writer=writer
|
transport=transport, tile_writer=writer
|
||||||
)
|
)
|
||||||
@@ -366,8 +427,7 @@ def test_ac4_downgraded_label_counted_and_persisted(tmp_path: Path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_ac5_429_honours_retry_after(tmp_path: Path) -> None:
|
def test_ac5_429_honours_retry_after(tmp_path: Path) -> None:
|
||||||
# Arrange
|
# Arrange — single present tile; tile GET returns 429 once then 200.
|
||||||
tiles = [_tile_entry(zoom=14, lat=45.0, lon=-122.0)]
|
|
||||||
state = {"attempts": 0}
|
state = {"attempts": 0}
|
||||||
|
|
||||||
def _factory(request: httpx.Request) -> httpx.Response:
|
def _factory(request: httpx.Request) -> httpx.Response:
|
||||||
@@ -377,8 +437,8 @@ def test_ac5_429_honours_retry_after(tmp_path: Path) -> None:
|
|||||||
return httpx.Response(200, content=b"\xff\xd8tile")
|
return httpx.Response(200, content=b"\xff\xd8tile")
|
||||||
|
|
||||||
transport = httpx.MockTransport(
|
transport = httpx.MockTransport(
|
||||||
_make_route_handler(
|
_make_inventory_handler(
|
||||||
list_response=_list_response(tiles),
|
present_count=1,
|
||||||
tile_response_factory=_factory,
|
tile_response_factory=_factory,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -403,8 +463,7 @@ def test_ac5_429_honours_retry_after(tmp_path: Path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_ac6_persistent_5xx_raises_satellite_provider_error(tmp_path: Path) -> None:
|
def test_ac6_persistent_5xx_raises_satellite_provider_error(tmp_path: Path) -> None:
|
||||||
# Arrange
|
# Arrange — single present tile; tile GET always 503.
|
||||||
tiles = [_tile_entry(zoom=14, lat=45.0, lon=-122.0)]
|
|
||||||
state = {"attempts": 0}
|
state = {"attempts": 0}
|
||||||
|
|
||||||
def _factory(request: httpx.Request) -> httpx.Response:
|
def _factory(request: httpx.Request) -> httpx.Response:
|
||||||
@@ -412,14 +471,12 @@ def test_ac6_persistent_5xx_raises_satellite_provider_error(tmp_path: Path) -> N
|
|||||||
return httpx.Response(503)
|
return httpx.Response(503)
|
||||||
|
|
||||||
transport = httpx.MockTransport(
|
transport = httpx.MockTransport(
|
||||||
_make_route_handler(
|
_make_inventory_handler(
|
||||||
list_response=_list_response(tiles),
|
present_count=1,
|
||||||
tile_response_factory=_factory,
|
tile_response_factory=_factory,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
(downloader, _logs, _writer, _enforcer, _sleeps) = _build_downloader(
|
(downloader, _logs, _writer, _enforcer, _sleeps) = _build_downloader(transport=transport)
|
||||||
transport=transport
|
|
||||||
)
|
|
||||||
|
|
||||||
# Act / Assert
|
# Act / Assert
|
||||||
with pytest.raises(SatelliteProviderError):
|
with pytest.raises(SatelliteProviderError):
|
||||||
@@ -433,8 +490,7 @@ def test_ac6_persistent_5xx_raises_satellite_provider_error(tmp_path: Path) -> N
|
|||||||
|
|
||||||
|
|
||||||
def test_ac7_401_fails_fast_no_retry(tmp_path: Path) -> None:
|
def test_ac7_401_fails_fast_no_retry(tmp_path: Path) -> None:
|
||||||
# Arrange
|
# Arrange — single present tile; tile GET returns 401.
|
||||||
tiles = [_tile_entry(zoom=14, lat=45.0, lon=-122.0)]
|
|
||||||
state = {"attempts": 0}
|
state = {"attempts": 0}
|
||||||
|
|
||||||
def _factory(request: httpx.Request) -> httpx.Response:
|
def _factory(request: httpx.Request) -> httpx.Response:
|
||||||
@@ -442,14 +498,12 @@ def test_ac7_401_fails_fast_no_retry(tmp_path: Path) -> None:
|
|||||||
return httpx.Response(401)
|
return httpx.Response(401)
|
||||||
|
|
||||||
transport = httpx.MockTransport(
|
transport = httpx.MockTransport(
|
||||||
_make_route_handler(
|
_make_inventory_handler(
|
||||||
list_response=_list_response(tiles),
|
present_count=1,
|
||||||
tile_response_factory=_factory,
|
tile_response_factory=_factory,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
(downloader, log_records, _writer, _enforcer, _sleeps) = _build_downloader(
|
(downloader, _log_records, _writer, _enforcer, _sleeps) = _build_downloader(transport=transport)
|
||||||
transport=transport
|
|
||||||
)
|
|
||||||
|
|
||||||
# Act / Assert
|
# Act / Assert
|
||||||
with pytest.raises(SatelliteProviderError):
|
with pytest.raises(SatelliteProviderError):
|
||||||
@@ -463,8 +517,7 @@ def test_ac7_401_fails_fast_no_retry(tmp_path: Path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_ac8_idempotent_rerun_yields_no_op(tmp_path: Path) -> None:
|
def test_ac8_idempotent_rerun_yields_no_op(tmp_path: Path) -> None:
|
||||||
# Arrange
|
# Arrange — 5 present tiles; both runs reach the same handler.
|
||||||
tiles = [_tile_entry(zoom=14, lat=45.0 + i * 0.001, lon=-122.0) for i in range(5)]
|
|
||||||
state = {"tile_gets": 0}
|
state = {"tile_gets": 0}
|
||||||
|
|
||||||
def _factory(request: httpx.Request) -> httpx.Response:
|
def _factory(request: httpx.Request) -> httpx.Response:
|
||||||
@@ -472,14 +525,12 @@ def test_ac8_idempotent_rerun_yields_no_op(tmp_path: Path) -> None:
|
|||||||
return httpx.Response(200, content=b"\xff\xd8tile")
|
return httpx.Response(200, content=b"\xff\xd8tile")
|
||||||
|
|
||||||
transport = httpx.MockTransport(
|
transport = httpx.MockTransport(
|
||||||
_make_route_handler(
|
_make_inventory_handler(
|
||||||
list_response=_list_response(tiles),
|
present_count=5,
|
||||||
tile_response_factory=_factory,
|
tile_response_factory=_factory,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
(downloader, _logs, _writer, _enforcer, _sleeps) = _build_downloader(
|
(downloader, _logs, _writer, _enforcer, _sleeps) = _build_downloader(transport=transport)
|
||||||
transport=transport
|
|
||||||
)
|
|
||||||
request = _make_request(cache_root=tmp_path)
|
request = _make_request(cache_root=tmp_path)
|
||||||
|
|
||||||
# Act — first run
|
# Act — first run
|
||||||
@@ -501,8 +552,7 @@ def test_ac8_idempotent_rerun_yields_no_op(tmp_path: Path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_ac9_cache_budget_pre_check_aborts(tmp_path: Path) -> None:
|
def test_ac9_cache_budget_pre_check_aborts(tmp_path: Path) -> None:
|
||||||
# Arrange
|
# Arrange — single present tile; budget enforcer rejects up-front.
|
||||||
tiles = [_tile_entry(zoom=14, lat=45.0, lon=-122.0, estimated_bytes=10_000)]
|
|
||||||
transport_state = {"tile_gets": 0}
|
transport_state = {"tile_gets": 0}
|
||||||
|
|
||||||
def _factory(request: httpx.Request) -> httpx.Response:
|
def _factory(request: httpx.Request) -> httpx.Response:
|
||||||
@@ -510,15 +560,13 @@ def test_ac9_cache_budget_pre_check_aborts(tmp_path: Path) -> None:
|
|||||||
return httpx.Response(200, content=b"\xff\xd8tile")
|
return httpx.Response(200, content=b"\xff\xd8tile")
|
||||||
|
|
||||||
transport = httpx.MockTransport(
|
transport = httpx.MockTransport(
|
||||||
_make_route_handler(
|
_make_inventory_handler(
|
||||||
list_response=_list_response(tiles),
|
present_count=1,
|
||||||
tile_response_factory=_factory,
|
tile_response_factory=_factory,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
enforcer = _StubBudgetEnforcer(
|
enforcer = _StubBudgetEnforcer(raise_on_call=CacheBudgetExceededError("no headroom"))
|
||||||
raise_on_call=CacheBudgetExceededError("no headroom")
|
(downloader, _log_records, _writer, _enforcer, _sleeps) = _build_downloader(
|
||||||
)
|
|
||||||
(downloader, log_records, _writer, _enforcer, _sleeps) = _build_downloader(
|
|
||||||
transport=transport, budget_enforcer=enforcer
|
transport=transport, budget_enforcer=enforcer
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -526,7 +574,7 @@ def test_ac9_cache_budget_pre_check_aborts(tmp_path: Path) -> None:
|
|||||||
with pytest.raises(CacheBudgetExceededError):
|
with pytest.raises(CacheBudgetExceededError):
|
||||||
downloader.download_tiles_for_area(_make_request(cache_root=tmp_path))
|
downloader.download_tiles_for_area(_make_request(cache_root=tmp_path))
|
||||||
assert transport_state["tile_gets"] == 0
|
assert transport_state["tile_gets"] == 0
|
||||||
assert enforcer.calls == [10_000]
|
assert enforcer.calls == [_DEFAULT_ESTIMATED_TILE_BYTES]
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
@@ -536,28 +584,19 @@ def test_ac9_cache_budget_pre_check_aborts(tmp_path: Path) -> None:
|
|||||||
|
|
||||||
def test_ac11_service_api_key_never_appears_in_logs(tmp_path: Path) -> None:
|
def test_ac11_service_api_key_never_appears_in_logs(tmp_path: Path) -> None:
|
||||||
# Arrange — exercise the failure path so the provider-failed ERROR
|
# Arrange — exercise the failure path so the provider-failed ERROR
|
||||||
# log fires (the code that explicitly redacts the auth header).
|
# log fires (the code that explicitly redacts the auth header). The
|
||||||
tiles = [_tile_entry(zoom=14, lat=45.0, lon=-122.0)]
|
# inventory POST returns 401 → fast-fail before any tile GET.
|
||||||
|
|
||||||
def _factory(request: httpx.Request) -> httpx.Response:
|
|
||||||
return httpx.Response(401)
|
|
||||||
|
|
||||||
transport = httpx.MockTransport(
|
transport = httpx.MockTransport(
|
||||||
_make_route_handler(
|
_make_inventory_handler(
|
||||||
list_response=_list_response(tiles),
|
inventory_response_override=httpx.Response(401),
|
||||||
tile_response_factory=_factory,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
(downloader, log_records, _writer, _enforcer, _sleeps) = _build_downloader(
|
(downloader, log_records, _writer, _enforcer, _sleeps) = _build_downloader(transport=transport)
|
||||||
transport=transport
|
|
||||||
)
|
|
||||||
with pytest.raises(SatelliteProviderError):
|
with pytest.raises(SatelliteProviderError):
|
||||||
downloader.download_tiles_for_area(_make_request(cache_root=tmp_path))
|
downloader.download_tiles_for_area(_make_request(cache_root=tmp_path))
|
||||||
|
|
||||||
# Assert
|
# Assert
|
||||||
flat = " ".join(
|
flat = " ".join(r.getMessage() + json.dumps(getattr(r, "kv", {})) for r in log_records)
|
||||||
r.getMessage() + json.dumps(getattr(r, "kv", {})) for r in log_records
|
|
||||||
)
|
|
||||||
assert _API_KEY not in flat
|
assert _API_KEY not in flat
|
||||||
assert "Bearer ***" in flat
|
assert "Bearer ***" in flat
|
||||||
|
|
||||||
@@ -568,12 +607,10 @@ def test_ac11_service_api_key_never_appears_in_logs(tmp_path: Path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_ac12_partial_journal_resumed_on_rerun(tmp_path: Path) -> None:
|
def test_ac12_partial_journal_resumed_on_rerun(tmp_path: Path) -> None:
|
||||||
# Arrange — 10 tiles; first run fetches all 10 successfully and
|
# Arrange — 10 present tiles; first run completes, then we truncate
|
||||||
# leaves a complete journal. A second run with the SAME request
|
# the journal to simulate a crash after 4 writes (clear
|
||||||
# must short-circuit (AC-8 covers that). To exercise AC-12 we
|
# `completed_at_iso`) so the second run must complete the remaining
|
||||||
# MANUALLY truncate the journal between runs to simulate a crash
|
# 6 tiles instead of short-circuiting on AC-8 idempotence.
|
||||||
# AFTER 4 tile-writes, BEFORE the completed_at_iso stamp.
|
|
||||||
tiles = [_tile_entry(zoom=14, lat=45.0 + i * 0.001, lon=-122.0) for i in range(10)]
|
|
||||||
state = {"tile_gets": 0}
|
state = {"tile_gets": 0}
|
||||||
|
|
||||||
def _factory(request: httpx.Request) -> httpx.Response:
|
def _factory(request: httpx.Request) -> httpx.Response:
|
||||||
@@ -581,14 +618,12 @@ def test_ac12_partial_journal_resumed_on_rerun(tmp_path: Path) -> None:
|
|||||||
return httpx.Response(200, content=b"\xff\xd8tile")
|
return httpx.Response(200, content=b"\xff\xd8tile")
|
||||||
|
|
||||||
transport = httpx.MockTransport(
|
transport = httpx.MockTransport(
|
||||||
_make_route_handler(
|
_make_inventory_handler(
|
||||||
list_response=_list_response(tiles),
|
present_count=10,
|
||||||
tile_response_factory=_factory,
|
tile_response_factory=_factory,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
(downloader, _logs, writer, _enforcer, _sleeps) = _build_downloader(
|
(downloader, _logs, writer, _enforcer, _sleeps) = _build_downloader(transport=transport)
|
||||||
transport=transport
|
|
||||||
)
|
|
||||||
request = _make_request(cache_root=tmp_path)
|
request = _make_request(cache_root=tmp_path)
|
||||||
|
|
||||||
# First run — completes
|
# First run — completes
|
||||||
@@ -637,8 +672,7 @@ def test_ac12_partial_journal_resumed_on_rerun(tmp_path: Path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_429_retry_after_http_date_form_parses(tmp_path: Path) -> None:
|
def test_429_retry_after_http_date_form_parses(tmp_path: Path) -> None:
|
||||||
# Arrange
|
# Arrange — single present tile; first tile GET 429 with HTTP-date Retry-After.
|
||||||
tiles = [_tile_entry(zoom=14, lat=45.0, lon=-122.0)]
|
|
||||||
state = {"attempts": 0}
|
state = {"attempts": 0}
|
||||||
future = (datetime.now(timezone.utc) + timedelta(seconds=20)).strftime(
|
future = (datetime.now(timezone.utc) + timedelta(seconds=20)).strftime(
|
||||||
"%a, %d %b %Y %H:%M:%S GMT"
|
"%a, %d %b %Y %H:%M:%S GMT"
|
||||||
@@ -651,8 +685,8 @@ def test_429_retry_after_http_date_form_parses(tmp_path: Path) -> None:
|
|||||||
return httpx.Response(200, content=b"\xff\xd8tile")
|
return httpx.Response(200, content=b"\xff\xd8tile")
|
||||||
|
|
||||||
transport = httpx.MockTransport(
|
transport = httpx.MockTransport(
|
||||||
_make_route_handler(
|
_make_inventory_handler(
|
||||||
list_response=_list_response(tiles),
|
present_count=1,
|
||||||
tile_response_factory=_factory,
|
tile_response_factory=_factory,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -676,15 +710,15 @@ def test_429_retry_after_http_date_form_parses(tmp_path: Path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_429_budget_exhaustion_raises_rate_limited_error(tmp_path: Path) -> None:
|
def test_429_budget_exhaustion_raises_rate_limited_error(tmp_path: Path) -> None:
|
||||||
# Arrange
|
# Arrange — single present tile; tile GET always 429 with very long
|
||||||
tiles = [_tile_entry(zoom=14, lat=45.0, lon=-122.0)]
|
# Retry-After. After the configured retry budget is exhausted the
|
||||||
|
# client raises RateLimitedError.
|
||||||
def _factory(request: httpx.Request) -> httpx.Response:
|
def _factory(request: httpx.Request) -> httpx.Response:
|
||||||
return httpx.Response(429, headers={"Retry-After": "300"})
|
return httpx.Response(429, headers={"Retry-After": "300"})
|
||||||
|
|
||||||
transport = httpx.MockTransport(
|
transport = httpx.MockTransport(
|
||||||
_make_route_handler(
|
_make_inventory_handler(
|
||||||
list_response=_list_response(tiles),
|
present_count=1,
|
||||||
tile_response_factory=_factory,
|
tile_response_factory=_factory,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -711,25 +745,27 @@ def test_429_budget_exhaustion_raises_rate_limited_error(tmp_path: Path) -> None
|
|||||||
|
|
||||||
|
|
||||||
def test_nfr_throughput_1000_tiles_under_budget(tmp_path: Path) -> None:
|
def test_nfr_throughput_1000_tiles_under_budget(tmp_path: Path) -> None:
|
||||||
# Arrange
|
# Arrange — 1000 present tiles. Use a bbox wide enough to enumerate
|
||||||
tiles = [
|
# ≥1000 tile coords at zoom 14 (≈0.022° per tile at ~zoom 14).
|
||||||
_tile_entry(zoom=14, lat=45.0 + i * 0.0001, lon=-122.0 + i * 0.0001)
|
|
||||||
for i in range(1000)
|
|
||||||
]
|
|
||||||
transport = httpx.MockTransport(
|
transport = httpx.MockTransport(
|
||||||
_make_route_handler(
|
_make_inventory_handler(
|
||||||
list_response=_list_response(tiles),
|
present_count=1000,
|
||||||
tile_response_factory=lambda r: httpx.Response(200, content=b"\xff\xd8tile"),
|
tile_response_factory=lambda r: httpx.Response(200, content=b"\xff\xd8tile"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
(downloader, _logs, _writer, _enforcer, _sleeps) = _build_downloader(
|
(downloader, _logs, _writer, _enforcer, _sleeps) = _build_downloader(transport=transport)
|
||||||
transport=transport
|
|
||||||
|
# Big bbox at zoom 14: ~ 32x32 tile span on this latitude is enough.
|
||||||
|
# 1° ≈ 45 tiles at zoom 14 in latitude → 0.75° gives ≈ 33 tiles → ~1089 tiles.
|
||||||
|
request = _make_request(
|
||||||
|
cache_root=tmp_path,
|
||||||
|
bbox=(44.0, -123.0, 44.75, -122.25),
|
||||||
)
|
)
|
||||||
|
|
||||||
import time as _time
|
import time as _time
|
||||||
|
|
||||||
t0 = _time.perf_counter()
|
t0 = _time.perf_counter()
|
||||||
report = downloader.download_tiles_for_area(_make_request(cache_root=tmp_path))
|
report = downloader.download_tiles_for_area(request)
|
||||||
elapsed = _time.perf_counter() - t0
|
elapsed = _time.perf_counter() - t0
|
||||||
|
|
||||||
# Assert — budget is generous; the goal is to catch an O(n^2)
|
# Assert — budget is generous; the goal is to catch an O(n^2)
|
||||||
|
|||||||
@@ -0,0 +1,380 @@
|
|||||||
|
"""AZ-836 — TlogRouteExtractor unit tests (Epic AZ-835 C1).
|
||||||
|
|
||||||
|
Covers AC-1..AC-10 of
|
||||||
|
``_docs/02_tasks/todo/AZ-836_tlog_route_extractor.md``:
|
||||||
|
|
||||||
|
* AC-1 (Derkachi happy path) — gated on the committed
|
||||||
|
``derkachi.tlog`` (5.8 MB). Asserts the coarsened route stays
|
||||||
|
inside the actual flight extent.
|
||||||
|
* AC-2 (active-segment trim) — synthetic stationary leading fixes.
|
||||||
|
* AC-3 (``max_waypoints=2``) — returns exactly two waypoints.
|
||||||
|
* AC-4 (``max_waypoints=100`` on small N) — returns all N waypoints
|
||||||
|
unchanged.
|
||||||
|
* AC-5 (missing tlog) — :class:`RouteExtractionError` with the path.
|
||||||
|
* AC-6 (no GPS) — :class:`RouteExtractionError` naming missing types.
|
||||||
|
* AC-7 (``RouteSpec`` shape) — frozen + slots + all provenance fields
|
||||||
|
populated.
|
||||||
|
* AC-8 (auto-tolerance) — 200-fix synthetic; converges to
|
||||||
|
``<= max_waypoints`` within 32 iterations.
|
||||||
|
* AC-9 (no extra I/O / DEBUG-only logging) — caplog level + transport
|
||||||
|
inspection.
|
||||||
|
* AC-10 (test surface meta) — satisfied by AC-1..AC-9 (custom DP
|
||||||
|
tolerance, custom region size are exercised below).
|
||||||
|
|
||||||
|
Tests use :func:`monkeypatch.setattr` to substitute
|
||||||
|
:func:`load_tlog_ground_truth` for synthetic record sets so the bulk
|
||||||
|
of the suite runs without pymavlink or the Derkachi binary.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from dataclasses import fields, is_dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from gps_denied_onboard.replay_input import tlog_route as tlog_route_module
|
||||||
|
from gps_denied_onboard.replay_input.errors import ReplayInputAdapterError
|
||||||
|
from gps_denied_onboard.replay_input.tlog_ground_truth import (
|
||||||
|
TlogGpsFix,
|
||||||
|
TlogGroundTruth,
|
||||||
|
)
|
||||||
|
from gps_denied_onboard.replay_input.tlog_route import (
|
||||||
|
RouteExtractionError,
|
||||||
|
RouteSpec,
|
||||||
|
extract_route_from_tlog,
|
||||||
|
)
|
||||||
|
|
||||||
|
_DERKACHI_TLOG = (
|
||||||
|
Path(__file__).resolve().parents[3]
|
||||||
|
/ "_docs"
|
||||||
|
/ "00_problem"
|
||||||
|
/ "input_data"
|
||||||
|
/ "flight_derkachi"
|
||||||
|
/ "derkachi.tlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fix(
|
||||||
|
*,
|
||||||
|
ts_ns: int = 0,
|
||||||
|
lat_deg: float,
|
||||||
|
lon_deg: float,
|
||||||
|
alt_m: float = 0.0,
|
||||||
|
vx_m_s: float = 0.0,
|
||||||
|
vy_m_s: float = 0.0,
|
||||||
|
) -> TlogGpsFix:
|
||||||
|
return TlogGpsFix(
|
||||||
|
ts_ns=ts_ns,
|
||||||
|
lat_deg=lat_deg,
|
||||||
|
lon_deg=lon_deg,
|
||||||
|
alt_m=alt_m,
|
||||||
|
hdg_deg=0.0,
|
||||||
|
vx_m_s=vx_m_s,
|
||||||
|
vy_m_s=vy_m_s,
|
||||||
|
vz_m_s=0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_loader(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
records: Iterable[TlogGpsFix],
|
||||||
|
*,
|
||||||
|
source: str = "GLOBAL_POSITION_INT",
|
||||||
|
) -> None:
|
||||||
|
"""Replace ``load_tlog_ground_truth`` with a synthetic-record stub."""
|
||||||
|
snapshot = tuple(records)
|
||||||
|
|
||||||
|
def _stub(path: Path) -> TlogGroundTruth:
|
||||||
|
return TlogGroundTruth(records=snapshot, source=source)
|
||||||
|
|
||||||
|
monkeypatch.setattr(tlog_route_module, "load_tlog_ground_truth", _stub)
|
||||||
|
|
||||||
|
|
||||||
|
def _flying_fix(
|
||||||
|
*,
|
||||||
|
ts_ns: int,
|
||||||
|
lat_deg: float,
|
||||||
|
lon_deg: float,
|
||||||
|
) -> TlogGpsFix:
|
||||||
|
"""A fix with speed + altitude well above the takeoff thresholds."""
|
||||||
|
return _fix(
|
||||||
|
ts_ns=ts_ns,
|
||||||
|
lat_deg=lat_deg,
|
||||||
|
lon_deg=lon_deg,
|
||||||
|
alt_m=100.0,
|
||||||
|
vx_m_s=10.0,
|
||||||
|
vy_m_s=0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# AC-1 ----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
not _DERKACHI_TLOG.is_file(),
|
||||||
|
reason="Derkachi reference tlog is not present in the checkout",
|
||||||
|
)
|
||||||
|
def test_ac1_real_derkachi_tlog_returns_route_inside_flight_extent() -> None:
|
||||||
|
# Act
|
||||||
|
route = extract_route_from_tlog(_DERKACHI_TLOG)
|
||||||
|
|
||||||
|
# Assert — bounds from raw GPS in the real tlog (probe-measured
|
||||||
|
# 2026-05-22): lat 50.0802..50.0840, lon 36.1075..36.1145. The
|
||||||
|
# AZ-836 spec quoted a tighter IMU-derived range (50.0808..50.0832
|
||||||
|
# / 36.1070..36.1134) from AZ-777 Phase 2's data_imu.csv analysis;
|
||||||
|
# GPS-based active-segment trim (speed >= 2 m/s AND AGL >= 5 m)
|
||||||
|
# legitimately reaches the wider GPS extent on takeoff/landing
|
||||||
|
# fringes. Spec bounds documented as "actual_flight_extent" in
|
||||||
|
# tests/fixtures/derkachi_c6/bbox.yaml are also IMU-derived.
|
||||||
|
assert 1 <= len(route.waypoints) <= 10
|
||||||
|
for lat, lon in route.waypoints:
|
||||||
|
assert 50.0800 <= lat <= 50.0840, (lat, lon)
|
||||||
|
assert 36.1070 <= lon <= 36.1145, (lat, lon)
|
||||||
|
assert route.source_tlog == _DERKACHI_TLOG
|
||||||
|
assert route.total_distance_meters > 0.0
|
||||||
|
start_idx, end_idx = route.source_segment
|
||||||
|
assert end_idx >= start_idx >= 0
|
||||||
|
|
||||||
|
|
||||||
|
# AC-2 ----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac2_stationary_leading_fixes_are_trimmed(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
# Arrange — 5 stationary leading fixes, then 10 flying fixes
|
||||||
|
stationary = [_fix(ts_ns=i, lat_deg=50.08, lon_deg=36.10, alt_m=0.0) for i in range(5)]
|
||||||
|
flying = [
|
||||||
|
_flying_fix(ts_ns=5 + i, lat_deg=50.08 + 0.0001 * i, lon_deg=36.10) for i in range(10)
|
||||||
|
]
|
||||||
|
_patch_loader(monkeypatch, stationary + flying)
|
||||||
|
tlog = tmp_path / "synthetic.tlog"
|
||||||
|
tlog.write_bytes(b"") # is_file() check only
|
||||||
|
|
||||||
|
# Act
|
||||||
|
route = extract_route_from_tlog(tlog)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
start_idx, end_idx = route.source_segment
|
||||||
|
assert start_idx == 5, f"expected leading 5 stationary fixes trimmed; got {start_idx}"
|
||||||
|
assert end_idx == 14
|
||||||
|
|
||||||
|
|
||||||
|
# AC-3 ----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac3_max_waypoints_two_returns_exactly_two_waypoints(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
# Arrange — 20-point straight-ish flight
|
||||||
|
records = [_flying_fix(ts_ns=i, lat_deg=50.08 + 0.0001 * i, lon_deg=36.10) for i in range(20)]
|
||||||
|
_patch_loader(monkeypatch, records)
|
||||||
|
tlog = tmp_path / "synthetic.tlog"
|
||||||
|
tlog.write_bytes(b"")
|
||||||
|
|
||||||
|
# Act
|
||||||
|
route = extract_route_from_tlog(
|
||||||
|
tlog, max_waypoints=2, min_takeoff_altitude_agl_m=0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert len(route.waypoints) == 2
|
||||||
|
|
||||||
|
|
||||||
|
# AC-4 ----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac4_max_waypoints_larger_than_segment_returns_all_points(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
# Arrange — 12 flying fixes; max_waypoints=100 should return all 12
|
||||||
|
records = [_flying_fix(ts_ns=i, lat_deg=50.08 + 0.0001 * i, lon_deg=36.10) for i in range(12)]
|
||||||
|
_patch_loader(monkeypatch, records)
|
||||||
|
tlog = tmp_path / "synthetic.tlog"
|
||||||
|
tlog.write_bytes(b"")
|
||||||
|
|
||||||
|
# Act
|
||||||
|
route = extract_route_from_tlog(
|
||||||
|
tlog, max_waypoints=100, min_takeoff_altitude_agl_m=0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert len(route.waypoints) == 12
|
||||||
|
|
||||||
|
|
||||||
|
# AC-5 ----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac5_missing_tlog_raises_route_extraction_error(tmp_path: Path) -> None:
|
||||||
|
# Arrange
|
||||||
|
missing = tmp_path / "does_not_exist.tlog"
|
||||||
|
|
||||||
|
# Act / Assert
|
||||||
|
with pytest.raises(RouteExtractionError) as exc_info:
|
||||||
|
extract_route_from_tlog(missing)
|
||||||
|
assert str(missing) in str(exc_info.value)
|
||||||
|
assert not isinstance(exc_info.value, FileNotFoundError)
|
||||||
|
|
||||||
|
|
||||||
|
# AC-6 ----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac6_tlog_without_gps_messages_raises_route_extraction_error(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
# Arrange — synthetic loader returns an empty record set
|
||||||
|
_patch_loader(monkeypatch, records=(), source="")
|
||||||
|
tlog = tmp_path / "no_gps.tlog"
|
||||||
|
tlog.write_bytes(b"")
|
||||||
|
|
||||||
|
# Act / Assert
|
||||||
|
with pytest.raises(RouteExtractionError) as exc_info:
|
||||||
|
extract_route_from_tlog(tlog)
|
||||||
|
message = str(exc_info.value)
|
||||||
|
assert "GLOBAL_POSITION_INT" in message
|
||||||
|
assert "GPS_RAW_INT" in message
|
||||||
|
|
||||||
|
|
||||||
|
# AC-7 ----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac7_route_spec_is_frozen_slots_with_all_provenance_fields(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
# Arrange
|
||||||
|
records = [_flying_fix(ts_ns=i, lat_deg=50.08 + 0.0001 * i, lon_deg=36.10) for i in range(6)]
|
||||||
|
_patch_loader(monkeypatch, records)
|
||||||
|
tlog = tmp_path / "synthetic.tlog"
|
||||||
|
tlog.write_bytes(b"")
|
||||||
|
|
||||||
|
# Act
|
||||||
|
route = extract_route_from_tlog(
|
||||||
|
tlog, region_size_meters=750.0, min_takeoff_altitude_agl_m=0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert — dataclass shape
|
||||||
|
assert is_dataclass(route)
|
||||||
|
assert getattr(RouteSpec, "__slots__", None) is not None
|
||||||
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||||
|
route.suggested_region_size_meters = 0.0 # type: ignore[misc]
|
||||||
|
field_names = {f.name for f in fields(route)}
|
||||||
|
assert field_names == {
|
||||||
|
"waypoints",
|
||||||
|
"suggested_region_size_meters",
|
||||||
|
"source_tlog",
|
||||||
|
"source_segment",
|
||||||
|
"total_distance_meters",
|
||||||
|
}
|
||||||
|
assert route.suggested_region_size_meters == pytest.approx(750.0)
|
||||||
|
assert route.source_tlog == tlog
|
||||||
|
assert route.source_segment == (0, 5)
|
||||||
|
assert route.total_distance_meters > 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# AC-8 ----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac8_auto_tolerance_converges_on_200_fix_synthetic(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
# Arrange — sinusoidal trajectory, 200 fixes
|
||||||
|
records = []
|
||||||
|
for i in range(200):
|
||||||
|
lat = 50.08 + 0.0005 * i / 200.0
|
||||||
|
lon = 36.10 + 0.0005 * math.sin(i / 10.0)
|
||||||
|
records.append(_flying_fix(ts_ns=i, lat_deg=lat, lon_deg=lon))
|
||||||
|
_patch_loader(monkeypatch, records)
|
||||||
|
tlog = tmp_path / "synthetic.tlog"
|
||||||
|
tlog.write_bytes(b"")
|
||||||
|
|
||||||
|
# Act
|
||||||
|
route = extract_route_from_tlog(
|
||||||
|
tlog,
|
||||||
|
max_waypoints=10,
|
||||||
|
douglas_peucker_tolerance_m=None,
|
||||||
|
min_takeoff_altitude_agl_m=0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert 2 <= len(route.waypoints) <= 10
|
||||||
|
assert route.total_distance_meters > 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# AC-9 ----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac9_no_warn_or_higher_logging_on_happy_path(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
# Arrange
|
||||||
|
records = [_flying_fix(ts_ns=i, lat_deg=50.08 + 0.0001 * i, lon_deg=36.10) for i in range(15)]
|
||||||
|
_patch_loader(monkeypatch, records)
|
||||||
|
tlog = tmp_path / "synthetic.tlog"
|
||||||
|
tlog.write_bytes(b"")
|
||||||
|
|
||||||
|
# Act
|
||||||
|
with caplog.at_level(logging.DEBUG, logger="gps_denied_onboard.replay_input.tlog_route"):
|
||||||
|
extract_route_from_tlog(tlog, min_takeoff_altitude_agl_m=0.0)
|
||||||
|
|
||||||
|
# Assert — only DEBUG emissions; no WARN/ERROR
|
||||||
|
levels = {r.levelno for r in caplog.records}
|
||||||
|
assert all(level <= logging.DEBUG for level in levels), levels
|
||||||
|
|
||||||
|
|
||||||
|
# AC-10 — extra surface (custom DP tolerance + region size + invalid input)
|
||||||
|
|
||||||
|
|
||||||
|
def test_custom_dp_tolerance_is_honored(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
# Arrange — straight 100-fix path; large tolerance should keep ~endpoints
|
||||||
|
records = [_flying_fix(ts_ns=i, lat_deg=50.08 + 0.0001 * i, lon_deg=36.10) for i in range(100)]
|
||||||
|
_patch_loader(monkeypatch, records)
|
||||||
|
tlog = tmp_path / "synthetic.tlog"
|
||||||
|
tlog.write_bytes(b"")
|
||||||
|
|
||||||
|
# Act
|
||||||
|
route = extract_route_from_tlog(
|
||||||
|
tlog,
|
||||||
|
max_waypoints=100,
|
||||||
|
douglas_peucker_tolerance_m=1000.0,
|
||||||
|
min_takeoff_altitude_agl_m=0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert — straight line + huge tolerance keeps only the endpoints
|
||||||
|
assert len(route.waypoints) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_max_waypoints_raises_value_error(tmp_path: Path) -> None:
|
||||||
|
# Act / Assert
|
||||||
|
with pytest.raises(ValueError, match="max_waypoints"):
|
||||||
|
extract_route_from_tlog(tmp_path / "irrelevant.tlog", max_waypoints=0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_region_size_raises_value_error(tmp_path: Path) -> None:
|
||||||
|
# Act / Assert
|
||||||
|
with pytest.raises(ValueError, match="region_size_meters"):
|
||||||
|
extract_route_from_tlog(tmp_path / "irrelevant.tlog", region_size_meters=0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_extraction_error_is_replay_input_adapter_error() -> None:
|
||||||
|
# Assert
|
||||||
|
assert issubclass(RouteExtractionError, ReplayInputAdapterError)
|
||||||
|
|
||||||
|
|
||||||
|
def test_active_segment_too_short_raises_route_extraction_error(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
# Arrange — only 1 flying fix among 10 stationary
|
||||||
|
records = [_fix(ts_ns=i, lat_deg=50.08, lon_deg=36.10) for i in range(10)]
|
||||||
|
records.insert(5, _flying_fix(ts_ns=5, lat_deg=50.08, lon_deg=36.10))
|
||||||
|
_patch_loader(monkeypatch, records)
|
||||||
|
tlog = tmp_path / "single_flying.tlog"
|
||||||
|
tlog.write_bytes(b"")
|
||||||
|
|
||||||
|
# Act / Assert
|
||||||
|
with pytest.raises(RouteExtractionError, match="active segment too short"):
|
||||||
|
extract_route_from_tlog(tlog)
|
||||||
Reference in New Issue
Block a user