mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 17:51:14 +00:00
[AZ-838] SatelliteProviderRouteClient + seed_route.py CLI (E-AZ-835 C2)
ci/woodpecker/push/02-build-push Pipeline failed
ci/woodpecker/push/02-build-push Pipeline failed
Operator-side HTTP client + CLI that takes a RouteSpec from AZ-836 and onboards it via satellite-provider's POST /api/satellite/route: pre-emptive AZ-809 validation, request submission, polling until mapsReady, and POST /api/satellite/tiles/inventory verify. Lives in c11_tile_manager (shared parent-suite HTTP/JWT plumbing, shared BUILD_C11_TILE_MANAGER gate); error hierarchy split off SatelliteProviderRouteError to keep the tile path and route path independent. 30 unit tests + 1 RUN_E2E-gated integration test. Pre-emptive validator tracks the actual AZ-809 server bounds (points [2,500], zoom [0,22]) instead of the AZ-838 spec's narrower client-only bounds; flagged as F1 in batch_107_cycle3_report.md for user decision (accept-and-update-spec / revert-to-spec). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -29,7 +29,11 @@ from gps_denied_onboard.components.c11_tile_manager.errors import (
|
||||
CacheBudgetExceededError,
|
||||
RateLimitedError,
|
||||
ResolutionRejectionError,
|
||||
RouteTerminalFailureError,
|
||||
RouteTransientError,
|
||||
RouteValidationError,
|
||||
SatelliteProviderError,
|
||||
SatelliteProviderRouteError,
|
||||
SessionNotActiveError,
|
||||
SignatureRejectedError,
|
||||
TileManagerError,
|
||||
@@ -41,6 +45,10 @@ from gps_denied_onboard.components.c11_tile_manager.interface import (
|
||||
TileDownloader,
|
||||
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 (
|
||||
PerFlightKeyManager,
|
||||
)
|
||||
@@ -74,7 +82,13 @@ __all__ = [
|
||||
"PublicKeyFingerprint",
|
||||
"RateLimitedError",
|
||||
"ResolutionRejectionError",
|
||||
"RouteSeedResult",
|
||||
"RouteTerminalFailureError",
|
||||
"RouteTransientError",
|
||||
"RouteValidationError",
|
||||
"SatelliteProviderError",
|
||||
"SatelliteProviderRouteClient",
|
||||
"SatelliteProviderRouteError",
|
||||
"SectorClassification",
|
||||
"SessionNotActiveError",
|
||||
"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
|
||||
download (AZ-316) paths share the family parent so cross-path callers
|
||||
can ``except TileManagerError`` to catch any C11-side terminal failure
|
||||
without enumerating subclasses.
|
||||
The C11 component carries TWO error hierarchies:
|
||||
|
||||
1. **Tile path** — rooted at :class:`TileManagerError`. Both the upload
|
||||
(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`
|
||||
/ :meth:`record_signature_rejection` called outside an active session.
|
||||
@@ -21,15 +30,32 @@ without enumerating subclasses.
|
||||
``resolution_m_per_px < 0.5``.
|
||||
* :class:`CacheBudgetExceededError` (AZ-316) — surfaced when c6's
|
||||
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 typing import Any
|
||||
|
||||
__all__ = [
|
||||
"CacheBudgetExceededError",
|
||||
"RateLimitedError",
|
||||
"ResolutionRejectionError",
|
||||
"RouteTerminalFailureError",
|
||||
"RouteTransientError",
|
||||
"RouteValidationError",
|
||||
"SatelliteProviderError",
|
||||
"SatelliteProviderRouteError",
|
||||
"SessionNotActiveError",
|
||||
"SignatureRejectedError",
|
||||
"TileManagerError",
|
||||
@@ -106,3 +132,81 @@ class CacheBudgetExceededError(TileManagerError):
|
||||
catch a cache-full failure. The original c6 error is preserved
|
||||
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
|
||||
Reference in New Issue
Block a user