[AZ-777] Phase 1 hotfix (z/x/y) + Phase 2 Derkachi seed + ops

Phase 1 hotfix:
- C11 HttpTileDownloader adapted to satellite-provider v2.0.0
  z/x/y inventory contract (bulk POST keyed by slippy-map coords).
- Unit tests rewritten to exercise the new inventory schema.
- E2E smoke test updated to match the v2.0.0 wire.

Phase 2 (Derkachi seed + smoke-validated on Jetson):
- tests/fixtures/derkachi_c6/{README,bbox.yaml,seed_region.py}
  drives POST /api/satellite/region against satellite-provider
  with Google Maps as the imagery source. Smoke run produced
  4 regions, 175 tiles, inventory 32/32.
- scripts/mint_dev_jwt.py + run-tests-jetson.sh auto-mint and
  export SATELLITE_PROVIDER_API_KEY using JWT_SECRET / JWT_ISSUER
  / JWT_AUDIENCE env vars (no host port mappings; e2e-runner
  reaches SP via internal docker network only).

Spec amendment: AZ-777 todo spec updated to record the
Google Maps imagery source decision and STOP-gate state.

AZ-777 Phase 3+ work is superseded by Epic AZ-835 (see next
commit).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-22 17:39:21 +03:00
parent 811b04e605
commit b15454b9a9
9 changed files with 924 additions and 182 deletions
@@ -36,7 +36,6 @@ from gps_denied_onboard.components.c11_tile_manager import (
request_hash,
)
_BASE_URL = "https://parent-suite.test"
_INVENTORY_PATH = "/api/satellite/tiles/inventory"
_TILES_PATH_PREFIX = "/tiles/"
@@ -111,14 +110,10 @@ class _StubTileWriter:
}
)
if call_index in self.rejected_indices:
raise _StubFreshnessRejection(
f"freshness rejected call_index={call_index}"
)
raise _StubFreshnessRejection(f"freshness rejected call_index={call_index}")
return self.labels_by_index.get(call_index, "fresh")
def tile_already_present(
self, *, zoom_level: int, lat: float, lon: float
) -> bool:
def tile_already_present(self, *, zoom_level: int, lat: float, lon: float) -> bool:
self.exists_calls.append((zoom_level, lat, lon))
return False
@@ -238,9 +233,9 @@ def _inventory_entry_for_coord(
) -> dict[str, Any]:
if not present:
return {
"tileZoom": int(zoom),
"tileX": int(x),
"tileY": int(y),
"z": int(zoom),
"x": int(x),
"y": int(y),
"locationHash": str(uuid4()),
"present": False,
"id": None,
@@ -251,9 +246,9 @@ def _inventory_entry_for_coord(
}
captured = captured_at or datetime(2026, 5, 13, 0, 0, 0, tzinfo=timezone.utc)
return {
"tileZoom": int(zoom),
"tileX": int(x),
"tileY": int(y),
"z": int(zoom),
"x": int(x),
"y": int(y),
"locationHash": str(uuid4()),
"present": True,
"id": str(uuid4()),
@@ -303,9 +298,9 @@ def _make_inventory_handler(
resolution = 0.5
results.append(
_inventory_entry_for_coord(
zoom=int(t["tileZoom"]),
x=int(t["tileX"]),
y=int(t["tileY"]),
zoom=int(t["z"]),
x=int(t["x"]),
y=int(t["y"]),
present=is_present,
resolution_m_per_px=resolution,
)
@@ -331,12 +326,8 @@ def _make_inventory_handler(
def test_ac1_100_tile_happy_path_writes_all(tmp_path: Path) -> None:
# Arrange — bbox at zoom 14 produces N coord candidates; the
# stub marks the first 100 as `present=true` and the rest absent.
transport = httpx.MockTransport(
_make_inventory_handler(present_count=100)
)
(downloader, _logs, writer, enforcer, _sleeps) = _build_downloader(
transport=transport
)
transport = httpx.MockTransport(_make_inventory_handler(present_count=100))
(downloader, _logs, writer, enforcer, _sleeps) = _build_downloader(transport=transport)
request = _make_request(cache_root=tmp_path)
# Act
@@ -366,9 +357,7 @@ def test_ac2_resolution_gate_rejects_sub_spec_tiles(tmp_path: Path) -> None:
resolution_override_for_first_n=(10, 0.3),
)
)
(downloader, log_records, writer, _enforcer, _sleeps) = _build_downloader(
transport=transport
)
(downloader, log_records, writer, _enforcer, _sleeps) = _build_downloader(transport=transport)
# Act
report = downloader.download_tiles_for_area(_make_request(cache_root=tmp_path))
@@ -377,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_downloaded == 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
@@ -402,7 +393,9 @@ def test_ac3_freshness_rejections_counted_and_run_continues(tmp_path: Path) -> N
assert report.tiles_downloaded == 5
assert report.outcome == DownloadOutcome.SUCCESS
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
@@ -483,9 +476,7 @@ def test_ac6_persistent_5xx_raises_satellite_provider_error(tmp_path: Path) -> N
tile_response_factory=_factory,
)
)
(downloader, _logs, _writer, _enforcer, _sleeps) = _build_downloader(
transport=transport
)
(downloader, _logs, _writer, _enforcer, _sleeps) = _build_downloader(transport=transport)
# Act / Assert
with pytest.raises(SatelliteProviderError):
@@ -512,9 +503,7 @@ def test_ac7_401_fails_fast_no_retry(tmp_path: Path) -> None:
tile_response_factory=_factory,
)
)
(downloader, log_records, _writer, _enforcer, _sleeps) = _build_downloader(
transport=transport
)
(downloader, _log_records, _writer, _enforcer, _sleeps) = _build_downloader(transport=transport)
# Act / Assert
with pytest.raises(SatelliteProviderError):
@@ -541,9 +530,7 @@ def test_ac8_idempotent_rerun_yields_no_op(tmp_path: Path) -> None:
tile_response_factory=_factory,
)
)
(downloader, _logs, _writer, _enforcer, _sleeps) = _build_downloader(
transport=transport
)
(downloader, _logs, _writer, _enforcer, _sleeps) = _build_downloader(transport=transport)
request = _make_request(cache_root=tmp_path)
# Act — first run
@@ -578,10 +565,8 @@ def test_ac9_cache_budget_pre_check_aborts(tmp_path: Path) -> None:
tile_response_factory=_factory,
)
)
enforcer = _StubBudgetEnforcer(
raise_on_call=CacheBudgetExceededError("no headroom")
)
(downloader, log_records, _writer, _enforcer, _sleeps) = _build_downloader(
enforcer = _StubBudgetEnforcer(raise_on_call=CacheBudgetExceededError("no headroom"))
(downloader, _log_records, _writer, _enforcer, _sleeps) = _build_downloader(
transport=transport, budget_enforcer=enforcer
)
@@ -606,16 +591,12 @@ def test_ac11_service_api_key_never_appears_in_logs(tmp_path: Path) -> None:
inventory_response_override=httpx.Response(401),
)
)
(downloader, log_records, _writer, _enforcer, _sleeps) = _build_downloader(
transport=transport
)
(downloader, log_records, _writer, _enforcer, _sleeps) = _build_downloader(transport=transport)
with pytest.raises(SatelliteProviderError):
downloader.download_tiles_for_area(_make_request(cache_root=tmp_path))
# Assert
flat = " ".join(
r.getMessage() + json.dumps(getattr(r, "kv", {})) for r in log_records
)
flat = " ".join(r.getMessage() + json.dumps(getattr(r, "kv", {})) for r in log_records)
assert _API_KEY not in flat
assert "Bearer ***" in flat
@@ -642,9 +623,7 @@ def test_ac12_partial_journal_resumed_on_rerun(tmp_path: Path) -> None:
tile_response_factory=_factory,
)
)
(downloader, _logs, writer, _enforcer, _sleeps) = _build_downloader(
transport=transport
)
(downloader, _logs, writer, _enforcer, _sleeps) = _build_downloader(transport=transport)
request = _make_request(cache_root=tmp_path)
# First run — completes
@@ -771,14 +750,10 @@ def test_nfr_throughput_1000_tiles_under_budget(tmp_path: Path) -> None:
transport = httpx.MockTransport(
_make_inventory_handler(
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(
transport=transport
)
(downloader, _logs, _writer, _enforcer, _sleeps) = _build_downloader(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.