mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 18:51:15 +00:00
[AZ-270] [AZ-272] [AZ-279] [AZ-281] [AZ-283] Compose root + FDR schema + 3 Layer-1 helpers
AZ-270: composition root with strategy registry, tier-gated lookup, topo-order construction, all-or-nothing teardown, StrategyNotLinkedError payload. AZ-272: orjson-backed FdrRecord serialise/parse with forward-compat for unknown payload + top-level fields and canonical overrun-record shape. AZ-279: pyproj-backed WGS84/ECEF/ENU + OSM slippy-map tile math with WgsConversionError for shape/range/zoom guards. AZ-281: strict EngineFilenameSchema build/parse/matches_host with anchored regex + enum validation; round-trip identity by construction. AZ-283: dtype-preserving (fp16/fp32) single + batch L2 normaliser with zero-norm safety and descriptor_metric() source-of-truth. pyproject.toml pins pyproj>=3.6 and orjson>=3.9 (named-backend deps per the AZ-272 / AZ-279 contracts). New DTOs LatLonAlt + BoundingBox and EngineCacheKey + HostCapabilities land in _types/ to back the helper contracts. 203 unit tests pass (64 new). Review verdict: PASS_WITH_WARNINGS; findings are perf-NFR deferrals + dep amendment + minor docstring polish. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,6 +5,17 @@ ones that have landed so consumers can depend on a stable public surface
|
||||
without reaching into the helper modules directly.
|
||||
"""
|
||||
|
||||
from gps_denied_onboard.helpers.descriptor_normaliser import (
|
||||
ALLOWED_DTYPES,
|
||||
DescriptorNormaliser,
|
||||
DescriptorNormaliserError,
|
||||
)
|
||||
from gps_denied_onboard.helpers.engine_filename_schema import (
|
||||
ALLOWED_PRECISIONS,
|
||||
ENGINE_SUFFIX,
|
||||
EngineFilenameSchema,
|
||||
EngineFilenameSchemaError,
|
||||
)
|
||||
from gps_denied_onboard.helpers.se3_utils import (
|
||||
SE3,
|
||||
Se3InvalidMatrixError,
|
||||
@@ -20,13 +31,30 @@ from gps_denied_onboard.helpers.sha256_sidecar import (
|
||||
Sha256Sidecar,
|
||||
Sha256SidecarError,
|
||||
)
|
||||
from gps_denied_onboard.helpers.wgs_converter import (
|
||||
MAX_ZOOM,
|
||||
WEB_MERCATOR_MAX_LAT_DEG,
|
||||
WgsConversionError,
|
||||
WgsConverter,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ALLOWED_DTYPES",
|
||||
"ALLOWED_PRECISIONS",
|
||||
"ENGINE_SUFFIX",
|
||||
"MAX_ZOOM",
|
||||
"SE3",
|
||||
"SIDECAR_SUFFIX",
|
||||
"WEB_MERCATOR_MAX_LAT_DEG",
|
||||
"DescriptorNormaliser",
|
||||
"DescriptorNormaliserError",
|
||||
"EngineFilenameSchema",
|
||||
"EngineFilenameSchemaError",
|
||||
"Se3InvalidMatrixError",
|
||||
"Sha256Sidecar",
|
||||
"Sha256SidecarError",
|
||||
"WgsConversionError",
|
||||
"WgsConverter",
|
||||
"adjoint",
|
||||
"exp_map",
|
||||
"is_valid_rotation",
|
||||
|
||||
@@ -1,14 +1,97 @@
|
||||
"""Descriptor-normalisation utility — STUB.
|
||||
"""L2 descriptor normaliser aligning cosine similarity to FAISS inner-product (AZ-283).
|
||||
|
||||
Concrete impl owned by AZ-283. Contract:
|
||||
`_docs/02_document/common-helpers/08_helper_descriptor_normaliser.md`.
|
||||
Public surface frozen by
|
||||
``_docs/02_document/contracts/shared_helpers/descriptor_normaliser.md`` v1.0.0.
|
||||
|
||||
Used on both the corpus side (C10 index build) and the query side (C2 runtime
|
||||
lookup). The two sides MUST go through the same helper so the FAISS HNSW
|
||||
search returns useful neighbours.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import Final
|
||||
|
||||
import numpy as np
|
||||
|
||||
__all__ = [
|
||||
"ALLOWED_DTYPES",
|
||||
"DescriptorNormaliser",
|
||||
"DescriptorNormaliserError",
|
||||
]
|
||||
|
||||
# Allowed input dtypes; anything else is rejected to keep the FAISS index and
|
||||
# query path on the same precision.
|
||||
ALLOWED_DTYPES: Final[tuple[np.dtype, ...]] = (
|
||||
np.dtype(np.float16),
|
||||
np.dtype(np.float32),
|
||||
)
|
||||
|
||||
_METRIC_VALUE: Final[str] = "inner_product"
|
||||
|
||||
|
||||
def l2_normalise(descriptors: Any) -> Any:
|
||||
"""L2-normalise a (N, D) descriptor matrix in-place semantics."""
|
||||
raise NotImplementedError("descriptor_normaliser concrete impl is AZ-283")
|
||||
class DescriptorNormaliserError(ValueError):
|
||||
"""Raised on shape / dtype violations (AZ-283)."""
|
||||
|
||||
|
||||
def _validate_dtype(arr: np.ndarray, label: str) -> None:
|
||||
if arr.dtype not in ALLOWED_DTYPES:
|
||||
raise DescriptorNormaliserError(
|
||||
f"{label}: dtype {arr.dtype} not in allowed set (float16, float32)"
|
||||
)
|
||||
|
||||
|
||||
class DescriptorNormaliser:
|
||||
"""Stateless L2-normalisation helper; dtype-preserving; zero-norm safe."""
|
||||
|
||||
@staticmethod
|
||||
def l2_normalise(descriptor: np.ndarray) -> np.ndarray:
|
||||
if not isinstance(descriptor, np.ndarray):
|
||||
raise DescriptorNormaliserError(
|
||||
f"l2_normalise: expected np.ndarray; got {type(descriptor).__name__}"
|
||||
)
|
||||
if descriptor.ndim != 1:
|
||||
raise DescriptorNormaliserError(
|
||||
f"l2_normalise: expected 1-D shape (D,); got shape {descriptor.shape}"
|
||||
)
|
||||
if descriptor.shape[0] < 1:
|
||||
raise DescriptorNormaliserError(
|
||||
f"l2_normalise: dimension must be >= 1; got shape {descriptor.shape}"
|
||||
)
|
||||
_validate_dtype(descriptor, "l2_normalise")
|
||||
in_dtype = descriptor.dtype
|
||||
# Compute norm in float32 to stabilise float16 inputs against overflow /
|
||||
# underflow; cast back to the caller dtype so we never silently up-cast.
|
||||
as_f32 = descriptor.astype(np.float32, copy=False)
|
||||
norm = float(np.linalg.norm(as_f32))
|
||||
if norm == 0.0:
|
||||
return np.zeros_like(descriptor)
|
||||
normalised_f32 = as_f32 / norm
|
||||
return normalised_f32.astype(in_dtype, copy=False)
|
||||
|
||||
@staticmethod
|
||||
def l2_normalise_batch(descriptors: np.ndarray) -> np.ndarray:
|
||||
if not isinstance(descriptors, np.ndarray):
|
||||
raise DescriptorNormaliserError(
|
||||
f"l2_normalise_batch: expected np.ndarray; got {type(descriptors).__name__}"
|
||||
)
|
||||
if descriptors.ndim != 2:
|
||||
raise DescriptorNormaliserError(
|
||||
f"l2_normalise_batch: expected 2-D shape (N, D); got shape {descriptors.shape}"
|
||||
)
|
||||
if descriptors.shape[0] < 1 or descriptors.shape[1] < 1:
|
||||
raise DescriptorNormaliserError(
|
||||
f"l2_normalise_batch: N and D must be >= 1; got shape {descriptors.shape}"
|
||||
)
|
||||
_validate_dtype(descriptors, "l2_normalise_batch")
|
||||
in_dtype = descriptors.dtype
|
||||
as_f32 = descriptors.astype(np.float32, copy=False)
|
||||
norms = np.linalg.norm(as_f32, axis=1, keepdims=True)
|
||||
# Avoid division-by-zero: leave zero rows as zero.
|
||||
safe = np.where(norms == 0.0, 1.0, norms)
|
||||
normalised_f32 = np.where(norms == 0.0, 0.0, as_f32 / safe)
|
||||
return normalised_f32.astype(in_dtype, copy=False)
|
||||
|
||||
@staticmethod
|
||||
def descriptor_metric() -> str:
|
||||
return _METRIC_VALUE
|
||||
|
||||
@@ -1,28 +1,127 @@
|
||||
"""TensorRT engine filename schema — STUB.
|
||||
"""Self-describing `.engine` filename schema (AZ-281 / D-C10-7).
|
||||
|
||||
D-C10-7 self-describing engine names. Concrete impl owned by AZ-281. Contract:
|
||||
`_docs/02_document/common-helpers/06_helper_engine_filename_schema.md`.
|
||||
Public surface frozen by
|
||||
``_docs/02_document/contracts/shared_helpers/engine_filename_schema.md`` v1.0.0.
|
||||
|
||||
Filename format: ``{model}__sm{SM}_jp{JP_dotted}_trt{TRT_dotted}_{precision}.engine``
|
||||
where ``model`` is ``[a-z0-9_]`` (no ``__``), versions are dotted
|
||||
``<major>.<minor>``, and ``precision`` is one of ``fp16``, ``int8``, ``mixed``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
from typing import Final
|
||||
|
||||
from gps_denied_onboard._types.manifests import EngineCacheKey, HostCapabilities
|
||||
|
||||
__all__ = [
|
||||
"ALLOWED_PRECISIONS",
|
||||
"ENGINE_SUFFIX",
|
||||
"EngineFilenameSchema",
|
||||
"EngineFilenameSchemaError",
|
||||
]
|
||||
|
||||
ENGINE_SUFFIX: Final[str] = ".engine"
|
||||
ALLOWED_PRECISIONS: Final[frozenset[str]] = frozenset({"fp16", "int8", "mixed"})
|
||||
|
||||
_MODEL_RE: Final[re.Pattern[str]] = re.compile(r"^[a-z0-9_]+$")
|
||||
_DOTTED_VERSION_RE: Final[re.Pattern[str]] = re.compile(r"^\d+\.\d+$")
|
||||
_FILENAME_RE: Final[re.Pattern[str]] = re.compile(
|
||||
r"^(?P<model>[a-z0-9_]+)__sm(?P<sm>\d+)_jp(?P<jetpack>\d+\.\d+)_trt(?P<trt>\d+\.\d+)_"
|
||||
r"(?P<precision>fp16|int8|mixed)\.engine$"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EngineFilename:
|
||||
"""Parsed parts of a self-describing engine filename."""
|
||||
class EngineFilenameSchemaError(ValueError):
|
||||
"""Raised by ``build`` / ``parse`` on validation / format violations (AZ-281)."""
|
||||
|
||||
model_name: str
|
||||
sm_arch: str
|
||||
jetpack_version: str
|
||||
tensorrt_version: str
|
||||
precision: str
|
||||
content_hash: str
|
||||
|
||||
def render(self) -> str:
|
||||
raise NotImplementedError("engine_filename_schema concrete impl is AZ-281")
|
||||
class EngineFilenameSchema:
|
||||
"""Stateless ``.engine`` filename builder / parser / host-match predicate."""
|
||||
|
||||
@classmethod
|
||||
def parse(cls, filename: str) -> EngineFilename:
|
||||
raise NotImplementedError("engine_filename_schema concrete impl is AZ-281")
|
||||
@staticmethod
|
||||
def build(model_name: str, sm: int, jetpack: str, trt: str, precision: str) -> str:
|
||||
_validate_model_name(model_name)
|
||||
_validate_sm(sm)
|
||||
_validate_version(jetpack, "jetpack")
|
||||
_validate_version(trt, "trt")
|
||||
_validate_precision(precision)
|
||||
return f"{model_name}__sm{sm}_jp{jetpack}_trt{trt}_{precision}{ENGINE_SUFFIX}"
|
||||
|
||||
@staticmethod
|
||||
def parse(filename: str) -> EngineCacheKey:
|
||||
if not isinstance(filename, str):
|
||||
raise EngineFilenameSchemaError(f"parse expects str; got {type(filename).__name__}")
|
||||
if not filename.endswith(ENGINE_SUFFIX):
|
||||
raise EngineFilenameSchemaError(
|
||||
f"parse: filename must end with {ENGINE_SUFFIX!r}; got {filename!r}"
|
||||
)
|
||||
match = _FILENAME_RE.match(filename)
|
||||
if not match:
|
||||
raise EngineFilenameSchemaError(
|
||||
f"parse: filename {filename!r} does not match the engine-schema format "
|
||||
"'{model}__sm{SM}_jp{JP}_trt{TRT}_{precision}.engine'"
|
||||
)
|
||||
model = match.group("model")
|
||||
if "__" in model:
|
||||
raise EngineFilenameSchemaError(
|
||||
f"parse: model segment {model!r} contains reserved separator '__'"
|
||||
)
|
||||
return EngineCacheKey(
|
||||
model_name=model,
|
||||
sm=int(match.group("sm")),
|
||||
jetpack=match.group("jetpack"),
|
||||
trt=match.group("trt"),
|
||||
precision=match.group("precision"),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def matches_host(filename: str, host_capabilities: HostCapabilities) -> bool:
|
||||
key = EngineFilenameSchema.parse(filename)
|
||||
return (
|
||||
key.sm == host_capabilities.sm
|
||||
and key.jetpack == host_capabilities.jetpack
|
||||
and key.trt == host_capabilities.trt
|
||||
)
|
||||
|
||||
|
||||
def _validate_model_name(model_name: str) -> None:
|
||||
if not isinstance(model_name, str):
|
||||
raise EngineFilenameSchemaError(f"model_name must be str; got {type(model_name).__name__}")
|
||||
if not model_name:
|
||||
raise EngineFilenameSchemaError("model_name must be a non-empty string")
|
||||
if "__" in model_name:
|
||||
raise EngineFilenameSchemaError(
|
||||
f"model_name {model_name!r} contains reserved separator '__'"
|
||||
)
|
||||
if not _MODEL_RE.match(model_name):
|
||||
raise EngineFilenameSchemaError(
|
||||
f"model_name {model_name!r} must match [a-z0-9_]+ (lowercase, digits, underscores)"
|
||||
)
|
||||
if len(model_name) > 64:
|
||||
raise EngineFilenameSchemaError(f"model_name {model_name!r} exceeds 64-character limit")
|
||||
|
||||
|
||||
def _validate_sm(sm: int) -> None:
|
||||
if not isinstance(sm, int) or isinstance(sm, bool):
|
||||
raise EngineFilenameSchemaError(f"sm must be a non-bool integer; got {sm!r}")
|
||||
if sm <= 0:
|
||||
raise EngineFilenameSchemaError(f"sm must be > 0; got {sm}")
|
||||
|
||||
|
||||
def _validate_version(version: str, field_name: str) -> None:
|
||||
if not isinstance(version, str):
|
||||
raise EngineFilenameSchemaError(f"{field_name} must be str; got {type(version).__name__}")
|
||||
if not _DOTTED_VERSION_RE.match(version):
|
||||
raise EngineFilenameSchemaError(
|
||||
f"{field_name} {version!r} must match dotted '<major>.<minor>' format"
|
||||
)
|
||||
|
||||
|
||||
def _validate_precision(precision: str) -> None:
|
||||
if precision not in ALLOWED_PRECISIONS:
|
||||
raise EngineFilenameSchemaError(
|
||||
f"precision {precision!r} not in allowed enum "
|
||||
f"{{{', '.join(sorted(ALLOWED_PRECISIONS))}}}"
|
||||
)
|
||||
|
||||
@@ -1,26 +1,170 @@
|
||||
"""WGS84 ↔ local-tangent-plane converter — STUB.
|
||||
"""WGS84 ↔ ECEF ↔ ENU ↔ slippy-map tile-xy conversions (AZ-279 / E-CC-HELPERS).
|
||||
|
||||
Concrete implementation is owned by AZ-279. Contract:
|
||||
`_docs/02_document/common-helpers/04_helper_wgs_converter.md`.
|
||||
Public surface frozen by
|
||||
``_docs/02_document/contracts/shared_helpers/wgs_converter.md`` v1.0.0.
|
||||
|
||||
Backed by ``pyproj`` for the geodesy primitives. Slippy-map tile math is hand
|
||||
rolled to match OSM's `{zoom}/{x}/{y}.jpg` convention exactly so the on-disk
|
||||
layout produced by ``satellite-provider`` round-trips byte-equal.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Final
|
||||
|
||||
def wgs84_to_ltp(
|
||||
lat_deg: float,
|
||||
lon_deg: float,
|
||||
alt_m: float,
|
||||
ref_lat_deg: float,
|
||||
ref_lon_deg: float,
|
||||
ref_alt_m: float,
|
||||
) -> tuple[float, float, float]:
|
||||
"""Convert a WGS-84 lat/lon/alt to local-tangent-plane east/north/up metres."""
|
||||
raise NotImplementedError("wgs_converter concrete impl is AZ-279")
|
||||
import numpy as np
|
||||
from pyproj import Transformer # type: ignore[import-not-found]
|
||||
|
||||
from gps_denied_onboard._types.geo import BoundingBox, LatLonAlt
|
||||
|
||||
__all__ = ["MAX_ZOOM", "WEB_MERCATOR_MAX_LAT_DEG", "WgsConversionError", "WgsConverter"]
|
||||
|
||||
|
||||
def ltp_to_wgs84(
|
||||
e_m: float, n_m: float, u_m: float, ref_lat_deg: float, ref_lon_deg: float, ref_alt_m: float
|
||||
) -> tuple[float, float, float]:
|
||||
"""Inverse of wgs84_to_ltp."""
|
||||
raise NotImplementedError("wgs_converter concrete impl is AZ-279")
|
||||
WEB_MERCATOR_MAX_LAT_DEG: Final[float] = 85.0511287798066
|
||||
MAX_ZOOM: Final[int] = 22
|
||||
|
||||
|
||||
class WgsConversionError(ValueError):
|
||||
"""Raised on shape / range violations in any ``WgsConverter`` static method."""
|
||||
|
||||
|
||||
_ECEF_FROM_LLA: Final[Transformer] = Transformer.from_crs("EPSG:4326", "EPSG:4978", always_xy=True)
|
||||
_LLA_FROM_ECEF: Final[Transformer] = Transformer.from_crs("EPSG:4978", "EPSG:4326", always_xy=True)
|
||||
|
||||
|
||||
def _validate_finite_latlonalt(p: LatLonAlt, label: str) -> None:
|
||||
if not (math.isfinite(p.lat_deg) and math.isfinite(p.lon_deg) and math.isfinite(p.alt_m)):
|
||||
raise WgsConversionError(f"{label}: non-finite component in {p!r}")
|
||||
if not (-90.0 <= p.lat_deg <= 90.0):
|
||||
raise WgsConversionError(f"{label}: latitude {p.lat_deg} outside [-90, 90]")
|
||||
if not (-180.0 <= p.lon_deg <= 180.0):
|
||||
raise WgsConversionError(f"{label}: longitude {p.lon_deg} outside [-180, 180]")
|
||||
|
||||
|
||||
def _enforce_ecef_shape(arr: np.ndarray, label: str) -> None:
|
||||
if not isinstance(arr, np.ndarray):
|
||||
raise WgsConversionError(
|
||||
f"{label}: expected np.ndarray of shape (3,); got {type(arr).__name__}"
|
||||
)
|
||||
if arr.shape != (3,):
|
||||
raise WgsConversionError(
|
||||
f"{label}: expected np.ndarray of shape (3,); got shape {arr.shape}"
|
||||
)
|
||||
if not np.all(np.isfinite(arr)):
|
||||
raise WgsConversionError(f"{label}: non-finite component in {arr!r}")
|
||||
|
||||
|
||||
class WgsConverter:
|
||||
"""Stateless WGS84 / ECEF / ENU / slippy-map-tile converter.
|
||||
|
||||
Every method is a pure function of its arguments; no module-level state
|
||||
other than the cached ``pyproj`` transformer pair.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def latlonalt_to_ecef(p: LatLonAlt) -> np.ndarray:
|
||||
_validate_finite_latlonalt(p, "latlonalt_to_ecef")
|
||||
x, y, z = _ECEF_FROM_LLA.transform(p.lon_deg, p.lat_deg, p.alt_m)
|
||||
return np.array([x, y, z], dtype=np.float64)
|
||||
|
||||
@staticmethod
|
||||
def ecef_to_latlonalt(p_ecef: np.ndarray) -> LatLonAlt:
|
||||
_enforce_ecef_shape(p_ecef, "ecef_to_latlonalt")
|
||||
lon, lat, alt = _LLA_FROM_ECEF.transform(
|
||||
float(p_ecef[0]), float(p_ecef[1]), float(p_ecef[2])
|
||||
)
|
||||
return LatLonAlt(lat_deg=float(lat), lon_deg=float(lon), alt_m=float(alt))
|
||||
|
||||
@staticmethod
|
||||
def latlonalt_to_local_enu(origin: LatLonAlt, p: LatLonAlt) -> np.ndarray:
|
||||
_validate_finite_latlonalt(origin, "latlonalt_to_local_enu/origin")
|
||||
_validate_finite_latlonalt(p, "latlonalt_to_local_enu/p")
|
||||
return _ecef_delta_to_enu(origin, WgsConverter.latlonalt_to_ecef(p))
|
||||
|
||||
@staticmethod
|
||||
def local_enu_to_latlonalt(origin: LatLonAlt, p_enu: np.ndarray) -> LatLonAlt:
|
||||
_validate_finite_latlonalt(origin, "local_enu_to_latlonalt/origin")
|
||||
_enforce_ecef_shape(p_enu, "local_enu_to_latlonalt/p_enu")
|
||||
origin_ecef = WgsConverter.latlonalt_to_ecef(origin)
|
||||
rotation = _enu_to_ecef_rotation(origin.lat_deg, origin.lon_deg)
|
||||
delta_ecef = rotation @ p_enu.astype(np.float64)
|
||||
return WgsConverter.ecef_to_latlonalt(origin_ecef + delta_ecef)
|
||||
|
||||
@staticmethod
|
||||
def latlon_to_tile_xy(zoom: int, lat: float, lon: float) -> tuple[int, int]:
|
||||
_validate_zoom(zoom)
|
||||
if not (math.isfinite(lat) and math.isfinite(lon)):
|
||||
raise WgsConversionError(f"latlon_to_tile_xy: non-finite input (lat={lat}, lon={lon})")
|
||||
if abs(lat) > WEB_MERCATOR_MAX_LAT_DEG:
|
||||
raise WgsConversionError(
|
||||
f"latlon_to_tile_xy: latitude {lat} outside Web-Mercator range "
|
||||
f"[-{WEB_MERCATOR_MAX_LAT_DEG}, {WEB_MERCATOR_MAX_LAT_DEG}]"
|
||||
)
|
||||
if not (-180.0 <= lon <= 180.0):
|
||||
raise WgsConversionError(f"latlon_to_tile_xy: longitude {lon} outside [-180, 180]")
|
||||
n = 1 << zoom
|
||||
lat_rad = math.radians(lat)
|
||||
x = math.floor((lon + 180.0) / 360.0 * n)
|
||||
y = math.floor(
|
||||
(1.0 - math.log(math.tan(lat_rad) + 1.0 / math.cos(lat_rad)) / math.pi) / 2.0 * n
|
||||
)
|
||||
x = max(0, min(x, n - 1))
|
||||
y = max(0, min(y, n - 1))
|
||||
return x, y
|
||||
|
||||
@staticmethod
|
||||
def tile_xy_to_latlon_bounds(zoom: int, x: int, y: int) -> BoundingBox:
|
||||
_validate_zoom(zoom)
|
||||
n = 1 << zoom
|
||||
if not (0 <= x < n and 0 <= y < n):
|
||||
raise WgsConversionError(
|
||||
f"tile_xy_to_latlon_bounds: tile (x={x}, y={y}) outside [0, {n}) at zoom {zoom}"
|
||||
)
|
||||
return BoundingBox(
|
||||
min_lat_deg=_tile_y_to_lat(y + 1, n),
|
||||
min_lon_deg=_tile_x_to_lon(x, n),
|
||||
max_lat_deg=_tile_y_to_lat(y, n),
|
||||
max_lon_deg=_tile_x_to_lon(x + 1, n),
|
||||
)
|
||||
|
||||
|
||||
def _validate_zoom(zoom: int) -> None:
|
||||
if not isinstance(zoom, int) or isinstance(zoom, bool):
|
||||
raise WgsConversionError(f"zoom must be a non-bool integer; got {zoom!r}")
|
||||
if not (0 <= zoom <= MAX_ZOOM):
|
||||
raise WgsConversionError(f"zoom {zoom} outside supported range [0, {MAX_ZOOM}]")
|
||||
|
||||
|
||||
def _tile_x_to_lon(x: int, n: int) -> float:
|
||||
return x / n * 360.0 - 180.0
|
||||
|
||||
|
||||
def _tile_y_to_lat(y: int, n: int) -> float:
|
||||
t = math.pi * (1.0 - 2.0 * y / n)
|
||||
return math.degrees(math.atan(math.sinh(t)))
|
||||
|
||||
|
||||
def _enu_to_ecef_rotation(lat_deg: float, lon_deg: float) -> np.ndarray:
|
||||
"""Rotation matrix mapping local ENU vectors to ECEF deltas at ``(lat, lon)``."""
|
||||
lat = math.radians(lat_deg)
|
||||
lon = math.radians(lon_deg)
|
||||
sin_lat = math.sin(lat)
|
||||
cos_lat = math.cos(lat)
|
||||
sin_lon = math.sin(lon)
|
||||
cos_lon = math.cos(lon)
|
||||
return np.array(
|
||||
[
|
||||
[-sin_lon, -sin_lat * cos_lon, cos_lat * cos_lon],
|
||||
[cos_lon, -sin_lat * sin_lon, cos_lat * sin_lon],
|
||||
[0.0, cos_lat, sin_lat],
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
|
||||
|
||||
def _ecef_delta_to_enu(origin: LatLonAlt, p_ecef: np.ndarray) -> np.ndarray:
|
||||
origin_ecef = WgsConverter.latlonalt_to_ecef(origin)
|
||||
delta = p_ecef - origin_ecef
|
||||
rotation = _enu_to_ecef_rotation(origin.lat_deg, origin.lon_deg)
|
||||
return rotation.T @ delta
|
||||
|
||||
Reference in New Issue
Block a user