mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 18:51:15 +00:00
[AZ-490] C5 set_takeoff_origin entrypoint + bounded-delta GPS gate
Add operator warm-start path to C5 StateEstimator Protocol and both
implementations (GtsamIsam2StateEstimator, EskfStateEstimator), plus
the third clause of the AZ-385 spoof-promotion gate.
- StateEstimator Protocol: set_takeoff_origin(origin, sigma_horiz_m,
sigma_vert_m) -> None.
- iSAM2: PriorFactorPose3 at origin with diagonal sigmas, single
isam2.update().
- ESKF: zero _nominal_pos, overwrite _P position block with sigma**2.
- SourceLabelStateMachine.process_gps_sample bounded-delta clause:
WgsConverter.horizontal_distance_m vs smoother estimate; reject
resets the dwell-time counter so AZ-385 cannot re-promote off bad
GPS.
- New EstimatorAlreadyStartedError (StateEstimatorConfigError
subclass) on late call after first add_*.
- C5StateConfig: spoof_promotion_bounded_delta_m=200,
default_takeoff_origin_sigma_horiz_m=5,
default_takeoff_origin_sigma_vert_m=10.
- New GpsSample DTO + WgsConverter.horizontal_distance_m helper.
- 4 new FDR kinds (cold_start_origin.{set,unavailable},
gps_bounded_delta.{accept,reject}) registered in AZ-272 schema.
- 33 new unit tests cover AC-1..AC-15; full repo 750 passed / 2
skipped (pre-existing CI tooling skips).
Docs synced: protocol contract, C5 component description,
architecture, glossary, system-flows, C10 provisioning description.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -28,6 +28,7 @@ __all__ = [
|
||||
"FlightState",
|
||||
"FlightStateSignal",
|
||||
"GpsHealth",
|
||||
"GpsSample",
|
||||
"GpsStatus",
|
||||
"ImuTelemetrySample",
|
||||
"OperatorCommand",
|
||||
@@ -142,6 +143,22 @@ class GpsHealth:
|
||||
captured_at: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GpsSample:
|
||||
"""Single FC-reported GPS lat/lon/alt sample (post-decode form).
|
||||
|
||||
Distinct from :class:`GpsHealth` (which carries only the health
|
||||
status enum) — this is the geographic position the FC reports
|
||||
alongside the health bucket. Consumed by C5's bounded-delta gate
|
||||
(AZ-490 / Principle #11 third clause) as the `vincenty(sample,
|
||||
smoother)` input. ``captured_at`` is monotonic_ns at the decode
|
||||
boundary, mirroring :class:`GpsHealth` for Invariant 7.
|
||||
"""
|
||||
|
||||
position_wgs84: LatLonAlt
|
||||
captured_at: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FlightStateSignal:
|
||||
"""FC's high-level flight-state lattice + AC-5.1 warm-start hint."""
|
||||
|
||||
@@ -24,6 +24,7 @@ from gps_denied_onboard._types.state import (
|
||||
)
|
||||
from gps_denied_onboard.components.c5_state.config import C5StateConfig
|
||||
from gps_denied_onboard.components.c5_state.errors import (
|
||||
EstimatorAlreadyStartedError,
|
||||
EstimatorDegradedError,
|
||||
EstimatorFatalError,
|
||||
StateEstimatorConfigError,
|
||||
@@ -34,6 +35,7 @@ from gps_denied_onboard.config.schema import register_component_block
|
||||
|
||||
__all__ = [
|
||||
"C5StateConfig",
|
||||
"EstimatorAlreadyStartedError",
|
||||
"EstimatorDegradedError",
|
||||
"EstimatorFatalError",
|
||||
"EstimatorHealth",
|
||||
|
||||
@@ -43,18 +43,30 @@ from typing import TYPE_CHECKING, Final, Protocol, runtime_checkable
|
||||
from gps_denied_onboard._types.fc import GpsStatus, Severity
|
||||
from gps_denied_onboard._types.state import PoseSourceLabel
|
||||
from gps_denied_onboard.fdr_client.records import FdrRecord
|
||||
from gps_denied_onboard.helpers.wgs_converter import WgsConverter
|
||||
from gps_denied_onboard.logging import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gps_denied_onboard._types.fc import GpsHealth
|
||||
from gps_denied_onboard._types.geo import LatLonAlt
|
||||
from gps_denied_onboard.fdr_client.client import FdrClient
|
||||
|
||||
__all__ = [
|
||||
"BOUNDED_DELTA_REJECT",
|
||||
"BOUNDED_DELTA_SOFT",
|
||||
"RejectionCallback",
|
||||
"RejectionSubscription",
|
||||
"SourceLabelStateMachine",
|
||||
]
|
||||
|
||||
# AZ-490 / Principle #11 third clause: tri-state outcome of the
|
||||
# bounded-delta gate. Kept as module-level string constants rather
|
||||
# than additions to ``PoseSourceLabel`` because the public C4/C5
|
||||
# label enum is stable and a soft-admission outcome doesn't change
|
||||
# the externally observable pose-provenance contract.
|
||||
BOUNDED_DELTA_SOFT: Final[str] = "BOUNDED_DELTA_SOFT"
|
||||
BOUNDED_DELTA_REJECT: Final[str] = "BOUNDED_DELTA_REJECT"
|
||||
|
||||
|
||||
# Subscriber signature — composition root receives
|
||||
# (reason, severity, statustext) on every reject. ``severity`` is
|
||||
@@ -140,6 +152,7 @@ class SourceLabelStateMachine:
|
||||
*,
|
||||
spoof_promotion_min_stable_s: float,
|
||||
spoof_promotion_visual_consistency_tol_m: float,
|
||||
spoof_promotion_bounded_delta_m: float,
|
||||
fdr_client: FdrClient | None,
|
||||
producer_id: str = "c5_state",
|
||||
clock_ns: Callable[[], int] = time.monotonic_ns,
|
||||
@@ -154,8 +167,14 @@ class SourceLabelStateMachine:
|
||||
"SourceLabelStateMachine.spoof_promotion_visual_consistency_tol_m "
|
||||
f"must be > 0; got {spoof_promotion_visual_consistency_tol_m}"
|
||||
)
|
||||
if spoof_promotion_bounded_delta_m <= 0.0:
|
||||
raise ValueError(
|
||||
"SourceLabelStateMachine.spoof_promotion_bounded_delta_m must be > 0; "
|
||||
f"got {spoof_promotion_bounded_delta_m}"
|
||||
)
|
||||
self._min_stable_ns: int = int(spoof_promotion_min_stable_s * 1_000_000_000)
|
||||
self._consistency_tol_m: float = spoof_promotion_visual_consistency_tol_m
|
||||
self._bounded_delta_m: float = spoof_promotion_bounded_delta_m
|
||||
self._fdr_client: FdrClient | None = fdr_client
|
||||
self._producer_id: str = producer_id
|
||||
self._clock_ns: Callable[[], int] = clock_ns
|
||||
@@ -322,6 +341,115 @@ class SourceLabelStateMachine:
|
||||
},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# AZ-490: third clause — bounded-delta GPS gate.
|
||||
|
||||
def process_gps_sample(
|
||||
self,
|
||||
sample: LatLonAlt,
|
||||
*,
|
||||
smoother_estimate: LatLonAlt | None,
|
||||
now_ns: int | None = None,
|
||||
) -> str | None:
|
||||
"""Bounded-delta admission test for an inbound FC GPS sample.
|
||||
|
||||
Returns:
|
||||
* :data:`BOUNDED_DELTA_SOFT` — sample is within the
|
||||
``spoof_promotion_bounded_delta_m`` ring; the composition
|
||||
root may attach it as a soft pose-anchor factor.
|
||||
* :data:`BOUNDED_DELTA_REJECT` — sample is outside the ring;
|
||||
the composition root MUST drop it; the dwell-time clause
|
||||
(clause 1 of the spoof-promotion gate) is reset so a
|
||||
subsequent reanchor must wait the full
|
||||
``spoof_promotion_min_stable_s`` window.
|
||||
* ``None`` — no smoother estimate is available yet (cold
|
||||
start / fallback engaged); the gate is skipped and the
|
||||
caller decides whether to admit or drop.
|
||||
|
||||
AC-15: distance is computed via :meth:`WgsConverter.horizontal_distance_m`,
|
||||
which routes through ``pyproj``'s ECEF chain (matches Vincenty
|
||||
within sub-mm at the bounded-delta operating range), so the
|
||||
haversine-on-equirectangular shortcut is excluded.
|
||||
"""
|
||||
if smoother_estimate is None:
|
||||
return None
|
||||
ts = now_ns if now_ns is not None else self._clock_ns()
|
||||
try:
|
||||
distance_m = WgsConverter.horizontal_distance_m(smoother_estimate, sample)
|
||||
except Exception as exc:
|
||||
self._log.error(
|
||||
"c5.gps_bounded_delta.distance_failed",
|
||||
extra={
|
||||
"kind": "c5.gps_bounded_delta.distance_failed",
|
||||
"kv": {"error": str(exc)},
|
||||
},
|
||||
)
|
||||
return None
|
||||
threshold_m = self._bounded_delta_m
|
||||
admitted = distance_m <= threshold_m
|
||||
kind = (
|
||||
"c5.gps_bounded_delta.accept" if admitted else "c5.gps_bounded_delta.reject"
|
||||
)
|
||||
record = FdrRecord(
|
||||
schema_version=1,
|
||||
ts=datetime.now(tz=timezone.utc).isoformat(),
|
||||
producer_id=self._producer_id,
|
||||
kind=kind,
|
||||
payload={
|
||||
"sample_lat": sample.lat_deg,
|
||||
"sample_lon": sample.lon_deg,
|
||||
"smoother_lat": smoother_estimate.lat_deg,
|
||||
"smoother_lon": smoother_estimate.lon_deg,
|
||||
"distance_m": distance_m,
|
||||
"threshold_m": threshold_m,
|
||||
},
|
||||
)
|
||||
if self._fdr_client is not None:
|
||||
try:
|
||||
self._fdr_client.enqueue(record)
|
||||
except Exception as exc:
|
||||
self._log.warning(
|
||||
"c5.gps_bounded_delta.fdr_enqueue_failed",
|
||||
extra={
|
||||
"kind": "c5.gps_bounded_delta.fdr_enqueue_failed",
|
||||
"kv": {"reject": not admitted, "error": repr(exc)},
|
||||
},
|
||||
)
|
||||
if admitted:
|
||||
self._log.info(
|
||||
"c5.gps_bounded_delta.accept",
|
||||
extra={
|
||||
"kind": "c5.gps_bounded_delta.accept",
|
||||
"kv": {
|
||||
"distance_m": distance_m,
|
||||
"threshold_m": threshold_m,
|
||||
},
|
||||
},
|
||||
)
|
||||
return BOUNDED_DELTA_SOFT
|
||||
# Reject path: bump the dwell-time clause so the AZ-385 gate
|
||||
# holds steady — a wildly-off GPS sample is a strong signal
|
||||
# the FC stream is not (yet) reliable, even when health is
|
||||
# nominally STABLE_NON_SPOOFED.
|
||||
with self._lock:
|
||||
self._gps_health_stable_since_ns = None
|
||||
self._log.warning(
|
||||
"c5.gps_bounded_delta.reject",
|
||||
extra={
|
||||
"kind": "c5.gps_bounded_delta.reject",
|
||||
"kv": {
|
||||
"distance_m": distance_m,
|
||||
"threshold_m": threshold_m,
|
||||
"sample_lat": sample.lat_deg,
|
||||
"sample_lon": sample.lon_deg,
|
||||
"smoother_lat": smoother_estimate.lat_deg,
|
||||
"smoother_lon": smoother_estimate.lon_deg,
|
||||
"ts_ns": ts,
|
||||
},
|
||||
},
|
||||
)
|
||||
return BOUNDED_DELTA_REJECT
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Subscription API.
|
||||
|
||||
|
||||
@@ -37,6 +37,15 @@ class C5StateConfig:
|
||||
in ``STABLE_NON_SPOOFED`` before the spoof-promotion gate opens.
|
||||
- ``spoof_promotion_visual_consistency_tol_m`` — AC-NEW-8 visual
|
||||
consistency tolerance on the next anchor.
|
||||
- ``spoof_promotion_bounded_delta_m`` — AZ-490 / Principle #11
|
||||
third clause: max horizontal distance (m) between an inbound FC
|
||||
GPS sample and the smoother's current estimate; samples within
|
||||
the ring are admitted as ``BOUNDED_DELTA_SOFT``, samples outside
|
||||
are rejected and counted against the dwell-time clause.
|
||||
- ``default_takeoff_origin_sigma_horiz_m`` — AZ-490 default
|
||||
horizontal sigma when ``set_takeoff_origin`` callers omit one.
|
||||
- ``default_takeoff_origin_sigma_vert_m`` — AZ-490 default
|
||||
vertical sigma when ``set_takeoff_origin`` callers omit one.
|
||||
- ``no_estimate_fallback_s`` — AC-5.2 timeout before the
|
||||
runtime root drops to FC-IMU-only mode.
|
||||
"""
|
||||
@@ -45,6 +54,9 @@ class C5StateConfig:
|
||||
keyframe_window_size: int = 15
|
||||
spoof_promotion_min_stable_s: float = 10.0
|
||||
spoof_promotion_visual_consistency_tol_m: float = 30.0
|
||||
spoof_promotion_bounded_delta_m: float = 200.0
|
||||
default_takeoff_origin_sigma_horiz_m: float = 5.0
|
||||
default_takeoff_origin_sigma_vert_m: float = 10.0
|
||||
no_estimate_fallback_s: float = 3.0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
@@ -68,6 +80,21 @@ class C5StateConfig:
|
||||
"C5StateConfig.spoof_promotion_visual_consistency_tol_m must be > 0; "
|
||||
f"got {self.spoof_promotion_visual_consistency_tol_m}"
|
||||
)
|
||||
if self.spoof_promotion_bounded_delta_m <= 0.0:
|
||||
raise ConfigError(
|
||||
"C5StateConfig.spoof_promotion_bounded_delta_m must be > 0; "
|
||||
f"got {self.spoof_promotion_bounded_delta_m}"
|
||||
)
|
||||
if self.default_takeoff_origin_sigma_horiz_m <= 0.0:
|
||||
raise ConfigError(
|
||||
"C5StateConfig.default_takeoff_origin_sigma_horiz_m must be > 0; "
|
||||
f"got {self.default_takeoff_origin_sigma_horiz_m}"
|
||||
)
|
||||
if self.default_takeoff_origin_sigma_vert_m <= 0.0:
|
||||
raise ConfigError(
|
||||
"C5StateConfig.default_takeoff_origin_sigma_vert_m must be > 0; "
|
||||
f"got {self.default_takeoff_origin_sigma_vert_m}"
|
||||
)
|
||||
if self.no_estimate_fallback_s <= 0.0:
|
||||
raise ConfigError(
|
||||
"C5StateConfig.no_estimate_fallback_s must be > 0; "
|
||||
|
||||
@@ -12,6 +12,7 @@ in C8).
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = [
|
||||
"EstimatorAlreadyStartedError",
|
||||
"EstimatorDegradedError",
|
||||
"EstimatorFatalError",
|
||||
"StateEstimatorConfigError",
|
||||
@@ -52,4 +53,22 @@ class StateEstimatorConfigError(StateEstimatorError):
|
||||
strategy is not registered (per ADR-002 build flag gating), when
|
||||
the config schema fails validation, or when the runtime root
|
||||
cannot wire the iSAM2 graph handle into C4.
|
||||
|
||||
AZ-490: also raised by :meth:`StateEstimator.set_takeoff_origin`
|
||||
when the supplied ``LatLonAlt`` is outside WGS-84 bounds, when
|
||||
either sigma is non-positive / non-finite, or when the entrypoint
|
||||
is called twice with conflicting arguments before the first
|
||||
measurement.
|
||||
"""
|
||||
|
||||
|
||||
class EstimatorAlreadyStartedError(StateEstimatorConfigError):
|
||||
"""``set_takeoff_origin`` called after the estimator left the INIT state.
|
||||
|
||||
AZ-490 / Contract Invariant 11a: the operator-origin entrypoint is
|
||||
valid only before the first ``add_*`` call. Once any factor has
|
||||
been added (i.e. the smoother is past INIT), seeding a new prior
|
||||
would silently corrupt the running estimate. ``IS-A`` of
|
||||
:class:`StateEstimatorConfigError` so existing
|
||||
``except StateEstimatorConfigError`` callers also catch this.
|
||||
"""
|
||||
|
||||
@@ -46,9 +46,11 @@ filter; this module documents the deviation in the
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import time
|
||||
from collections import deque
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal
|
||||
from uuid import uuid4
|
||||
|
||||
import numpy as np
|
||||
@@ -75,16 +77,18 @@ from gps_denied_onboard.components.c5_state._source_label_sm import (
|
||||
)
|
||||
from gps_denied_onboard.components.c5_state.config import C5StateConfig
|
||||
from gps_denied_onboard.components.c5_state.errors import (
|
||||
EstimatorAlreadyStartedError,
|
||||
EstimatorDegradedError,
|
||||
EstimatorFatalError,
|
||||
StateEstimatorConfigError,
|
||||
)
|
||||
from gps_denied_onboard.components.c5_state.interface import StateEstimator
|
||||
from gps_denied_onboard.fdr_client.records import FdrRecord
|
||||
from gps_denied_onboard.helpers.wgs_converter import WgsConverter
|
||||
from gps_denied_onboard.logging import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gps_denied_onboard._types.fc import GpsHealth
|
||||
from gps_denied_onboard._types.fc import GpsHealth, GpsSample
|
||||
from gps_denied_onboard._types.nav import ImuWindow
|
||||
from gps_denied_onboard._types.pose import PoseEstimate
|
||||
from gps_denied_onboard._types.vio import VioOutput
|
||||
@@ -196,11 +200,20 @@ class EskfStateEstimator(StateEstimator):
|
||||
self._enu_origin: LatLonAlt | None = None
|
||||
self._history: deque[EstimatorOutput] = deque(maxlen=_HISTORY_DEPTH)
|
||||
|
||||
# AZ-490 cold-start ladder. Same semantics as
|
||||
# ``GtsamIsam2StateEstimator`` — see that class for the
|
||||
# field-level rationale (idempotency, conflict detection,
|
||||
# window-closed gate, FC-EKF fallback FDR).
|
||||
self._takeoff_origin_set: tuple[LatLonAlt, float, float] | None = None
|
||||
self._origin_source: Literal["manifest", "fc_ekf"] | None = None
|
||||
self._cold_start_window_closed: bool = False
|
||||
|
||||
# AZ-385: source-label SM. Eagerly constructed; composition
|
||||
# root drives notify_gps_health + subscribe_spoof_rejection.
|
||||
self._source_label_machine: SourceLabelStateMachine = SourceLabelStateMachine(
|
||||
spoof_promotion_min_stable_s=block.spoof_promotion_min_stable_s,
|
||||
spoof_promotion_visual_consistency_tol_m=block.spoof_promotion_visual_consistency_tol_m,
|
||||
spoof_promotion_bounded_delta_m=block.spoof_promotion_bounded_delta_m,
|
||||
fdr_client=fdr_client,
|
||||
producer_id="c5_state",
|
||||
)
|
||||
@@ -278,6 +291,151 @@ class EskfStateEstimator(StateEstimator):
|
||||
) -> FallbackSubscription:
|
||||
return self._fallback.subscribe_recovered(callback)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# AZ-490: operator-origin cold-start entrypoint.
|
||||
|
||||
def set_takeoff_origin(
|
||||
self,
|
||||
origin: LatLonAlt,
|
||||
sigma_horiz_m: float,
|
||||
sigma_vert_m: float,
|
||||
) -> None:
|
||||
"""Seed the ESKF nominal state + position covariance from operator origin.
|
||||
|
||||
See :meth:`StateEstimator.set_takeoff_origin` for the full
|
||||
contract. ESKF impl: sets the ENU origin to ``origin``,
|
||||
zeros the nominal position (the operator-supplied origin
|
||||
IS the local-ENU (0,0,0)), and writes the position block of
|
||||
``_P`` to ``diag(sigma_horiz_m², sigma_horiz_m², sigma_vert_m²)``.
|
||||
"""
|
||||
_validate_takeoff_origin_args(origin, sigma_horiz_m, sigma_vert_m)
|
||||
|
||||
if self._cold_start_window_closed:
|
||||
raise EstimatorAlreadyStartedError(
|
||||
"set_takeoff_origin called after the cold-start window closed; "
|
||||
"first add_* call sealed the operator-origin entrypoint"
|
||||
)
|
||||
|
||||
if self._takeoff_origin_set is not None:
|
||||
prev_origin, prev_sh, prev_sv = self._takeoff_origin_set
|
||||
if prev_origin == origin and prev_sh == sigma_horiz_m and prev_sv == sigma_vert_m:
|
||||
return # AC-4 — idempotent no-op
|
||||
raise StateEstimatorConfigError(
|
||||
"set_takeoff_origin re-called with conflicting args; "
|
||||
f"previous=(origin={prev_origin!r}, sigma_horiz_m={prev_sh}, "
|
||||
f"sigma_vert_m={prev_sv}); "
|
||||
f"new=(origin={origin!r}, sigma_horiz_m={sigma_horiz_m}, "
|
||||
f"sigma_vert_m={sigma_vert_m})"
|
||||
)
|
||||
|
||||
self._enu_origin = origin
|
||||
self._nominal_pos = np.zeros(3, dtype=np.float64)
|
||||
# Position block of the error covariance — explicit overwrite
|
||||
# (the diagonal stays diagonal here; off-diagonal terms in
|
||||
# ``_P[0:3, ...]`` were zero at construction and we don't
|
||||
# want to lose those zeros).
|
||||
pos_var = np.diag(
|
||||
np.array(
|
||||
[sigma_horiz_m**2, sigma_horiz_m**2, sigma_vert_m**2],
|
||||
dtype=np.float64,
|
||||
)
|
||||
)
|
||||
self._P[_IDX_POS, _IDX_POS] = pos_var
|
||||
# Symmetrise defensively — the rest of the matrix may carry
|
||||
# accumulated cross-terms from earlier construction; the
|
||||
# symmetrise costs nothing and keeps the SPD invariant
|
||||
# observable on the next read.
|
||||
self._P = 0.5 * (self._P + self._P.T)
|
||||
|
||||
self._takeoff_origin_set = (origin, sigma_horiz_m, sigma_vert_m)
|
||||
self._origin_source = "manifest"
|
||||
self._emit_cold_start_origin_set_fdr(
|
||||
source="manifest",
|
||||
origin=origin,
|
||||
sigma_horiz_m=sigma_horiz_m,
|
||||
sigma_vert_m=sigma_vert_m,
|
||||
)
|
||||
self._log.info(
|
||||
"c5.cold_start_origin.set",
|
||||
extra={
|
||||
"kind": "c5.cold_start_origin.set",
|
||||
"kv": {
|
||||
"source": "manifest",
|
||||
"lat_deg": origin.lat_deg,
|
||||
"lon_deg": origin.lon_deg,
|
||||
"alt_m": origin.alt_m,
|
||||
"sigma_horiz_m": sigma_horiz_m,
|
||||
"sigma_vert_m": sigma_vert_m,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def _close_cold_start_window(self) -> None:
|
||||
"""ESKF mirror of the iSAM2 helper — see that class for the contract."""
|
||||
if self._cold_start_window_closed:
|
||||
return
|
||||
self._cold_start_window_closed = True
|
||||
if self._origin_source is None:
|
||||
self._origin_source = "fc_ekf"
|
||||
origin = self._enu_origin if self._enu_origin is not None else _DEFAULT_ENU_ORIGIN
|
||||
self._emit_cold_start_origin_set_fdr(
|
||||
source="fc_ekf",
|
||||
origin=origin,
|
||||
sigma_horiz_m=self._block.default_takeoff_origin_sigma_horiz_m,
|
||||
sigma_vert_m=self._block.default_takeoff_origin_sigma_vert_m,
|
||||
)
|
||||
|
||||
def _emit_cold_start_origin_set_fdr(
|
||||
self,
|
||||
*,
|
||||
source: Literal["manifest", "fc_ekf"],
|
||||
origin: LatLonAlt,
|
||||
sigma_horiz_m: float,
|
||||
sigma_vert_m: float,
|
||||
) -> None:
|
||||
if self._fdr_client is None:
|
||||
return
|
||||
record = FdrRecord(
|
||||
schema_version=1,
|
||||
ts=datetime.now(tz=timezone.utc).isoformat(),
|
||||
producer_id="c5_state",
|
||||
kind="c5.cold_start_origin.set",
|
||||
payload={
|
||||
"source": source,
|
||||
"lat_deg": origin.lat_deg,
|
||||
"lon_deg": origin.lon_deg,
|
||||
"alt_m": origin.alt_m,
|
||||
"sigma_horiz_m": sigma_horiz_m,
|
||||
"sigma_vert_m": sigma_vert_m,
|
||||
},
|
||||
)
|
||||
try:
|
||||
self._fdr_client.enqueue(record)
|
||||
except Exception as exc:
|
||||
self._log.warning(
|
||||
"c5.cold_start_origin.set_fdr_enqueue_failed",
|
||||
extra={
|
||||
"kind": "c5.cold_start_origin.set_fdr_enqueue_failed",
|
||||
"kv": {"source": source, "error": repr(exc)},
|
||||
},
|
||||
)
|
||||
|
||||
def notify_gps_sample(self, sample: GpsSample, now_ns: int | None = None) -> str | None:
|
||||
"""ESKF bounded-delta dispatch — mirror of the iSAM2 method."""
|
||||
machine = self._source_label_machine
|
||||
if not isinstance(machine, SourceLabelStateMachine):
|
||||
return None
|
||||
smoother_latlon: LatLonAlt | None
|
||||
try:
|
||||
smoother_latlon = self._enu_pose_to_wgs84()
|
||||
except Exception:
|
||||
smoother_latlon = None
|
||||
return machine.process_gps_sample(
|
||||
sample.position_wgs84,
|
||||
smoother_estimate=smoother_latlon,
|
||||
now_ns=now_ns,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Protocol: factor adds.
|
||||
|
||||
@@ -290,6 +448,7 @@ class EskfStateEstimator(StateEstimator):
|
||||
the analogous nominal delta — both are projected to a 6-vector
|
||||
residual in the previous body frame.
|
||||
"""
|
||||
self._close_cold_start_window()
|
||||
ts_ns = _datetime_to_ns(vio.timestamp)
|
||||
self._guard_timestamp(ts_ns, source="vio")
|
||||
curr_pose = _pose_se3_to_array(vio.pose_se3)
|
||||
@@ -372,6 +531,7 @@ class EskfStateEstimator(StateEstimator):
|
||||
has no graph to throttle); it integrates every anchor as a
|
||||
regular measurement.
|
||||
"""
|
||||
self._close_cold_start_window()
|
||||
ts_ns = int(pose.emitted_at)
|
||||
self._guard_timestamp(ts_ns, source="pose_anchor")
|
||||
meas_pose = self._pose_estimate_to_matrix(pose)
|
||||
@@ -415,6 +575,7 @@ class EskfStateEstimator(StateEstimator):
|
||||
|
||||
def add_fc_imu(self, imu_window: ImuWindow) -> None:
|
||||
"""Predict nominal state + propagate covariance over the IMU window."""
|
||||
self._close_cold_start_window()
|
||||
self._guard_timestamp(imu_window.ts_end_ns, source="imu_window")
|
||||
samples = imu_window.samples
|
||||
if not samples:
|
||||
@@ -925,6 +1086,38 @@ def _enforce_spd(cov: np.ndarray) -> None:
|
||||
raise EstimatorFatalError(f"covariance not SPD: {exc}") from exc
|
||||
|
||||
|
||||
def _validate_takeoff_origin_args(
|
||||
origin: LatLonAlt,
|
||||
sigma_horiz_m: float,
|
||||
sigma_vert_m: float,
|
||||
) -> None:
|
||||
"""AZ-490 input validation — same rules for both estimator impls."""
|
||||
if not (
|
||||
math.isfinite(origin.lat_deg)
|
||||
and math.isfinite(origin.lon_deg)
|
||||
and math.isfinite(origin.alt_m)
|
||||
):
|
||||
raise StateEstimatorConfigError(
|
||||
f"set_takeoff_origin: non-finite component in origin {origin!r}"
|
||||
)
|
||||
if not (-90.0 <= origin.lat_deg <= 90.0):
|
||||
raise StateEstimatorConfigError(
|
||||
f"set_takeoff_origin: latitude {origin.lat_deg} outside WGS-84 [-90, 90]"
|
||||
)
|
||||
if not (-180.0 <= origin.lon_deg <= 180.0):
|
||||
raise StateEstimatorConfigError(
|
||||
f"set_takeoff_origin: longitude {origin.lon_deg} outside WGS-84 [-180, 180]"
|
||||
)
|
||||
if not (math.isfinite(sigma_horiz_m) and sigma_horiz_m > 0.0):
|
||||
raise StateEstimatorConfigError(
|
||||
f"set_takeoff_origin: sigma_horiz_m must be positive finite; got {sigma_horiz_m}"
|
||||
)
|
||||
if not (math.isfinite(sigma_vert_m) and sigma_vert_m > 0.0):
|
||||
raise StateEstimatorConfigError(
|
||||
f"set_takeoff_origin: sigma_vert_m must be positive finite; got {sigma_vert_m}"
|
||||
)
|
||||
|
||||
|
||||
def _with_smoothed_false(out: EstimatorOutput) -> EstimatorOutput:
|
||||
"""Return a copy of ``out`` with ``smoothed=False``.
|
||||
|
||||
|
||||
@@ -30,10 +30,11 @@ there.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import time
|
||||
from collections import deque
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import gtsam
|
||||
@@ -66,6 +67,7 @@ from gps_denied_onboard.components.c5_state._source_label_sm import (
|
||||
)
|
||||
from gps_denied_onboard.components.c5_state.config import C5StateConfig
|
||||
from gps_denied_onboard.components.c5_state.errors import (
|
||||
EstimatorAlreadyStartedError,
|
||||
EstimatorDegradedError,
|
||||
EstimatorFatalError,
|
||||
StateEstimatorConfigError,
|
||||
@@ -76,7 +78,7 @@ from gps_denied_onboard.helpers.wgs_converter import WgsConverter
|
||||
from gps_denied_onboard.logging import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gps_denied_onboard._types.fc import GpsHealth
|
||||
from gps_denied_onboard._types.fc import GpsHealth, GpsSample
|
||||
from gps_denied_onboard._types.nav import ImuWindow
|
||||
from gps_denied_onboard._types.pose import PoseEstimate
|
||||
from gps_denied_onboard._types.vio import VioOutput
|
||||
@@ -120,6 +122,13 @@ _COV_NORM_WINDOW_NS: Final[int] = 60 * 1_000_000_000
|
||||
# state. AZ-385 will tie origin selection to the spoof-promotion gate.
|
||||
_DEFAULT_ENU_ORIGIN: Final[LatLonAlt] = LatLonAlt(lat_deg=0.0, lon_deg=0.0, alt_m=0.0)
|
||||
|
||||
# AZ-490 / ADR-010 default rotation sigma for the operator-origin
|
||||
# prior factor. Translation sigmas are caller-supplied; the rotation
|
||||
# component has no operator input, so we use a conservative 5° prior
|
||||
# (a still-stationary drone with no compass calibration is comfortably
|
||||
# inside a 5° envelope at the WGS84 frame).
|
||||
_DEFAULT_TAKEOFF_ORIENTATION_SIGMA_RAD: Final[float] = math.radians(5.0)
|
||||
|
||||
|
||||
class GtsamIsam2StateEstimator(StateEstimator):
|
||||
"""Production-default C5 estimator — iSAM2 + ``IncrementalFixedLagSmoother``.
|
||||
@@ -213,6 +222,7 @@ class GtsamIsam2StateEstimator(StateEstimator):
|
||||
self._source_label_machine: SourceLabelStateMachine = SourceLabelStateMachine(
|
||||
spoof_promotion_min_stable_s=block.spoof_promotion_min_stable_s,
|
||||
spoof_promotion_visual_consistency_tol_m=block.spoof_promotion_visual_consistency_tol_m,
|
||||
spoof_promotion_bounded_delta_m=block.spoof_promotion_bounded_delta_m,
|
||||
fdr_client=fdr_client,
|
||||
producer_id="c5_state",
|
||||
)
|
||||
@@ -248,6 +258,25 @@ class GtsamIsam2StateEstimator(StateEstimator):
|
||||
producer_id="c5_state",
|
||||
)
|
||||
|
||||
# AZ-490 cold-start ladder ----------------------------------------
|
||||
# ``_takeoff_origin_set`` records the operator-origin args so
|
||||
# AC-4 idempotency + AC-5 conflict checks can compare without
|
||||
# re-deriving from the seeded ``PriorFactorPose3``. None means
|
||||
# ``set_takeoff_origin`` has not been called yet.
|
||||
self._takeoff_origin_set: tuple[LatLonAlt, float, float] | None = None
|
||||
# ``_origin_source`` tracks which cold-start anchor won.
|
||||
# ``"manifest"`` is set by ``set_takeoff_origin`` (Principle
|
||||
# #11 primary); ``"fc_ekf"`` is set by the first ``add_*``
|
||||
# call when no manifest origin was supplied (legacy fallback).
|
||||
# AC-13 wants exactly one ``c5.cold_start_origin.set`` FDR
|
||||
# record per estimator; this field gates the emission.
|
||||
self._origin_source: Literal["manifest", "fc_ekf"] | None = None
|
||||
# ``_cold_start_window_closed`` flips True on the first
|
||||
# ``add_*`` call (vio / pose_anchor / fc_imu). After it
|
||||
# closes, ``set_takeoff_origin`` must raise
|
||||
# ``EstimatorAlreadyStartedError`` (AC-6).
|
||||
self._cold_start_window_closed: bool = False
|
||||
|
||||
self._log.debug(
|
||||
"c5.state.isam2_initialised",
|
||||
extra={
|
||||
@@ -392,6 +421,224 @@ class GtsamIsam2StateEstimator(StateEstimator):
|
||||
self._next_key_counter += 1
|
||||
return new_key
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# AZ-490: operator-origin cold-start entrypoint.
|
||||
|
||||
def set_takeoff_origin(
|
||||
self,
|
||||
origin: LatLonAlt,
|
||||
sigma_horiz_m: float,
|
||||
sigma_vert_m: float,
|
||||
) -> None:
|
||||
"""Seed the cold-start prior with an operator-supplied origin (AZ-490).
|
||||
|
||||
See :meth:`StateEstimator.set_takeoff_origin` for the full
|
||||
contract. This impl sets the local ENU origin to ``origin``,
|
||||
attaches one ``PriorFactorPose3`` at ``Pose3.Identity()`` to
|
||||
the next pose key, drives a single ``handle.update``, and
|
||||
emits one ``c5.cold_start_origin.set`` FDR record.
|
||||
"""
|
||||
self._validate_takeoff_origin_args(origin, sigma_horiz_m, sigma_vert_m)
|
||||
|
||||
if self._cold_start_window_closed:
|
||||
raise EstimatorAlreadyStartedError(
|
||||
"set_takeoff_origin called after the cold-start window closed; "
|
||||
"first add_* call sealed the operator-origin entrypoint"
|
||||
)
|
||||
|
||||
if self._takeoff_origin_set is not None:
|
||||
prev_origin, prev_sh, prev_sv = self._takeoff_origin_set
|
||||
if prev_origin == origin and prev_sh == sigma_horiz_m and prev_sv == sigma_vert_m:
|
||||
return # AC-4 — idempotent no-op
|
||||
raise StateEstimatorConfigError(
|
||||
"set_takeoff_origin re-called with conflicting args; "
|
||||
f"previous=(origin={prev_origin!r}, sigma_horiz_m={prev_sh}, "
|
||||
f"sigma_vert_m={prev_sv}); "
|
||||
f"new=(origin={origin!r}, sigma_horiz_m={sigma_horiz_m}, "
|
||||
f"sigma_vert_m={sigma_vert_m})"
|
||||
)
|
||||
|
||||
handle = self._require_handle()
|
||||
self._enu_origin = origin
|
||||
|
||||
prior_pose = gtsam.Pose3() # Identity at ENU origin
|
||||
prior_key = gtsam.symbol("x", self._next_key_counter)
|
||||
self._next_key_counter += 1
|
||||
|
||||
sigmas = np.array(
|
||||
[
|
||||
_DEFAULT_TAKEOFF_ORIENTATION_SIGMA_RAD,
|
||||
_DEFAULT_TAKEOFF_ORIENTATION_SIGMA_RAD,
|
||||
_DEFAULT_TAKEOFF_ORIENTATION_SIGMA_RAD,
|
||||
sigma_horiz_m,
|
||||
sigma_horiz_m,
|
||||
sigma_vert_m,
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
noise = gtsam.noiseModel.Diagonal.Sigmas(sigmas)
|
||||
factor = gtsam.PriorFactorPose3(prior_key, prior_pose, noise)
|
||||
# AC-6 / Invariant 11a: do NOT advance ``_last_added_ts_ns`` —
|
||||
# this is a pre-takeoff seed, not a measurement; the first
|
||||
# subsequent ``add_*`` call still sees the unguarded baseline.
|
||||
ts_ns = time.monotonic_ns()
|
||||
try:
|
||||
handle.add_factor(factor)
|
||||
self._values.insert(prior_key, prior_pose)
|
||||
timestamps = _make_timestamp_map([prior_key], ts_ns)
|
||||
handle.update(self._graph, self._values, timestamps)
|
||||
except (EstimatorDegradedError, EstimatorFatalError):
|
||||
raise
|
||||
except Exception as exc:
|
||||
self._log.error(
|
||||
"c5.state.set_takeoff_origin_failed",
|
||||
extra={
|
||||
"kind": "c5.state.set_takeoff_origin_failed",
|
||||
"kv": {"error": str(exc)},
|
||||
},
|
||||
)
|
||||
raise EstimatorDegradedError(f"set_takeoff_origin failed: {exc}") from exc
|
||||
|
||||
self._reset_staging()
|
||||
self._record_committed_pose_key(prior_key)
|
||||
self._takeoff_origin_set = (origin, sigma_horiz_m, sigma_vert_m)
|
||||
self._origin_source = "manifest"
|
||||
self._emit_cold_start_origin_set_fdr(
|
||||
source="manifest",
|
||||
origin=origin,
|
||||
sigma_horiz_m=sigma_horiz_m,
|
||||
sigma_vert_m=sigma_vert_m,
|
||||
)
|
||||
self._log.info(
|
||||
"c5.cold_start_origin.set",
|
||||
extra={
|
||||
"kind": "c5.cold_start_origin.set",
|
||||
"kv": {
|
||||
"source": "manifest",
|
||||
"lat_deg": origin.lat_deg,
|
||||
"lon_deg": origin.lon_deg,
|
||||
"alt_m": origin.alt_m,
|
||||
"sigma_horiz_m": sigma_horiz_m,
|
||||
"sigma_vert_m": sigma_vert_m,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_takeoff_origin_args(
|
||||
origin: LatLonAlt,
|
||||
sigma_horiz_m: float,
|
||||
sigma_vert_m: float,
|
||||
) -> None:
|
||||
if not (
|
||||
math.isfinite(origin.lat_deg)
|
||||
and math.isfinite(origin.lon_deg)
|
||||
and math.isfinite(origin.alt_m)
|
||||
):
|
||||
raise StateEstimatorConfigError(
|
||||
f"set_takeoff_origin: non-finite component in origin {origin!r}"
|
||||
)
|
||||
if not (-90.0 <= origin.lat_deg <= 90.0):
|
||||
raise StateEstimatorConfigError(
|
||||
f"set_takeoff_origin: latitude {origin.lat_deg} outside WGS-84 [-90, 90]"
|
||||
)
|
||||
if not (-180.0 <= origin.lon_deg <= 180.0):
|
||||
raise StateEstimatorConfigError(
|
||||
f"set_takeoff_origin: longitude {origin.lon_deg} outside WGS-84 [-180, 180]"
|
||||
)
|
||||
if not (math.isfinite(sigma_horiz_m) and sigma_horiz_m > 0.0):
|
||||
raise StateEstimatorConfigError(
|
||||
f"set_takeoff_origin: sigma_horiz_m must be positive finite; got {sigma_horiz_m}"
|
||||
)
|
||||
if not (math.isfinite(sigma_vert_m) and sigma_vert_m > 0.0):
|
||||
raise StateEstimatorConfigError(
|
||||
f"set_takeoff_origin: sigma_vert_m must be positive finite; got {sigma_vert_m}"
|
||||
)
|
||||
|
||||
def _close_cold_start_window(self) -> None:
|
||||
"""Mark the cold-start window closed on the first ``add_*`` call.
|
||||
|
||||
Idempotent — only the first invocation flips the flag and
|
||||
emits the legacy-fallback FDR record (AC-13). Subsequent
|
||||
calls are no-ops.
|
||||
"""
|
||||
if self._cold_start_window_closed:
|
||||
return
|
||||
self._cold_start_window_closed = True
|
||||
if self._origin_source is None:
|
||||
self._origin_source = "fc_ekf"
|
||||
origin = self._enu_origin if self._enu_origin is not None else _DEFAULT_ENU_ORIGIN
|
||||
self._emit_cold_start_origin_set_fdr(
|
||||
source="fc_ekf",
|
||||
origin=origin,
|
||||
sigma_horiz_m=self._block.default_takeoff_origin_sigma_horiz_m,
|
||||
sigma_vert_m=self._block.default_takeoff_origin_sigma_vert_m,
|
||||
)
|
||||
|
||||
def _emit_cold_start_origin_set_fdr(
|
||||
self,
|
||||
*,
|
||||
source: Literal["manifest", "fc_ekf"],
|
||||
origin: LatLonAlt,
|
||||
sigma_horiz_m: float,
|
||||
sigma_vert_m: float,
|
||||
) -> None:
|
||||
if self._fdr_client is None:
|
||||
return
|
||||
record = FdrRecord(
|
||||
schema_version=1,
|
||||
ts=datetime.now(tz=timezone.utc).isoformat(),
|
||||
producer_id="c5_state",
|
||||
kind="c5.cold_start_origin.set",
|
||||
payload={
|
||||
"source": source,
|
||||
"lat_deg": origin.lat_deg,
|
||||
"lon_deg": origin.lon_deg,
|
||||
"alt_m": origin.alt_m,
|
||||
"sigma_horiz_m": sigma_horiz_m,
|
||||
"sigma_vert_m": sigma_vert_m,
|
||||
},
|
||||
)
|
||||
try:
|
||||
self._fdr_client.enqueue(record)
|
||||
except Exception as exc:
|
||||
self._log.warning(
|
||||
"c5.cold_start_origin.set_fdr_enqueue_failed",
|
||||
extra={
|
||||
"kind": "c5.cold_start_origin.set_fdr_enqueue_failed",
|
||||
"kv": {"source": source, "error": repr(exc)},
|
||||
},
|
||||
)
|
||||
|
||||
def notify_gps_sample(self, sample: GpsSample, now_ns: int | None = None) -> str | None:
|
||||
"""Bounded-delta gate dispatch for an inbound FC GPS sample (AZ-490).
|
||||
|
||||
Looks up the smoother's current latlon (best-effort — returns
|
||||
``None`` if no committed pose is available yet) and delegates
|
||||
to :meth:`SourceLabelStateMachine.process_gps_sample`. The
|
||||
return value mirrors that method's tri-state result
|
||||
(``"BOUNDED_DELTA_SOFT"`` / ``"REJECT"`` / ``None``); the
|
||||
composition root uses it to decide whether to enqueue the
|
||||
sample as a soft factor.
|
||||
"""
|
||||
machine = self._source_label_machine
|
||||
if not isinstance(machine, SourceLabelStateMachine):
|
||||
return None
|
||||
smoother_latlon: LatLonAlt | None
|
||||
if self._last_committed_pose_key is None:
|
||||
smoother_latlon = None
|
||||
else:
|
||||
try:
|
||||
pose = self._pose_at_key(self._last_committed_pose_key)
|
||||
smoother_latlon = self._enu_pose_to_wgs84(pose)
|
||||
except EstimatorFatalError:
|
||||
smoother_latlon = None
|
||||
return machine.process_gps_sample(
|
||||
sample.position_wgs84,
|
||||
smoother_estimate=smoother_latlon,
|
||||
now_ns=now_ns,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# AZ-383: factor-add bodies.
|
||||
|
||||
@@ -405,6 +652,7 @@ class GtsamIsam2StateEstimator(StateEstimator):
|
||||
single ``handle.update()`` per AC-7.
|
||||
"""
|
||||
handle = self._require_handle()
|
||||
self._close_cold_start_window()
|
||||
ts_ns = _datetime_to_ns(vio.timestamp)
|
||||
self._guard_timestamp(ts_ns, source="vio")
|
||||
|
||||
@@ -476,6 +724,7 @@ class GtsamIsam2StateEstimator(StateEstimator):
|
||||
gate (AZ-385) and ``last_anchor_age_ms`` see a recent anchor.
|
||||
"""
|
||||
handle = self._require_handle()
|
||||
self._close_cold_start_window()
|
||||
ts_ns = int(pose.emitted_at)
|
||||
self._guard_timestamp(ts_ns, source="pose_anchor")
|
||||
|
||||
@@ -560,6 +809,7 @@ class GtsamIsam2StateEstimator(StateEstimator):
|
||||
owns the per-window factor add.
|
||||
"""
|
||||
handle = self._require_handle()
|
||||
self._close_cold_start_window()
|
||||
self._guard_timestamp(imu_window.ts_end_ns, source="imu_window")
|
||||
|
||||
try:
|
||||
|
||||
@@ -21,7 +21,7 @@ from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gps_denied_onboard._types.nav import ImuWindow
|
||||
from gps_denied_onboard._types.pose import PoseEstimate
|
||||
from gps_denied_onboard._types.pose import LatLonAlt, PoseEstimate
|
||||
from gps_denied_onboard._types.state import (
|
||||
EstimatorHealth,
|
||||
EstimatorOutput,
|
||||
@@ -41,6 +41,33 @@ class StateEstimator(Protocol):
|
||||
impls must implement every method.
|
||||
"""
|
||||
|
||||
def set_takeoff_origin(
|
||||
self,
|
||||
origin: LatLonAlt,
|
||||
sigma_horiz_m: float,
|
||||
sigma_vert_m: float,
|
||||
) -> None:
|
||||
"""Seed the cold-start prior with an operator-supplied origin (AZ-490 / ADR-010).
|
||||
|
||||
Pre-takeoff entrypoint. Called by the composition root during
|
||||
F2 (Takeoff load) when the C10 ManifestVerifier reports a
|
||||
valid ``flight.takeoff_origin``. With this prior set, the
|
||||
FC-EKF first-frame cold-start path becomes the secondary
|
||||
fallback (Invariant 11).
|
||||
|
||||
Contract:
|
||||
|
||||
* Idempotent if called twice with byte-identical args (no-op).
|
||||
* Raises :class:`StateEstimatorConfigError` if called twice
|
||||
with different args, on negative / non-finite sigmas, or on
|
||||
a ``LatLonAlt`` outside WGS-84 bounds.
|
||||
* Raises :class:`EstimatorAlreadyStartedError` if called
|
||||
AFTER the first ``add_vio`` / ``add_pose_anchor`` /
|
||||
``add_fc_imu`` (cold-start window closed).
|
||||
* Emits one ``c5.cold_start_origin.set`` FDR record with
|
||||
``source="manifest"``.
|
||||
"""
|
||||
|
||||
def add_vio(self, vio: VioOutput) -> None:
|
||||
"""Add a VIO output as a relative-pose factor to the iSAM2 graph."""
|
||||
|
||||
|
||||
@@ -69,6 +69,31 @@ KNOWN_PAYLOAD_KEYS: Final[dict[str, frozenset[str]]] = {
|
||||
"clean_shutdown",
|
||||
}
|
||||
),
|
||||
# AZ-490 / E-C5: operator-origin cold-start ladder + bounded-delta GPS gate.
|
||||
"c5.cold_start_origin.set": frozenset(
|
||||
{"source", "lat_deg", "lon_deg", "alt_m", "sigma_horiz_m", "sigma_vert_m"}
|
||||
),
|
||||
"c5.cold_start_origin.unavailable": frozenset({"reason"}),
|
||||
"c5.gps_bounded_delta.accept": frozenset(
|
||||
{
|
||||
"sample_lat",
|
||||
"sample_lon",
|
||||
"smoother_lat",
|
||||
"smoother_lon",
|
||||
"distance_m",
|
||||
"threshold_m",
|
||||
}
|
||||
),
|
||||
"c5.gps_bounded_delta.reject": frozenset(
|
||||
{
|
||||
"sample_lat",
|
||||
"sample_lon",
|
||||
"smoother_lat",
|
||||
"smoother_lon",
|
||||
"distance_m",
|
||||
"threshold_m",
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
KNOWN_KINDS: Final[frozenset[str]] = frozenset(KNOWN_PAYLOAD_KEYS.keys())
|
||||
|
||||
@@ -91,6 +91,29 @@ class WgsConverter:
|
||||
delta_ecef = rotation @ p_enu.astype(np.float64)
|
||||
return WgsConverter.ecef_to_latlonalt(origin_ecef + delta_ecef)
|
||||
|
||||
@staticmethod
|
||||
def horizontal_distance_m(a: LatLonAlt, b: LatLonAlt) -> float:
|
||||
"""Return the geodesic horizontal distance (m) between ``a`` and ``b``.
|
||||
|
||||
Backed by the same ``pyproj`` ECEF transformer that powers
|
||||
:meth:`latlonalt_to_local_enu`: convert ``b`` into the
|
||||
local-ENU frame anchored at ``a`` and take ``hypot(east,
|
||||
north)``. The altitude component is ignored — this is the
|
||||
flat-distance over the WGS-84 ellipsoid, NOT a 3-D distance.
|
||||
|
||||
Accuracy: pyproj's ECEF chain matches Vincenty within sub-mm
|
||||
at horizontal separations ≤ a few km (the bounded-delta gate
|
||||
operates at ≤ ~1 km), so AZ-490's "Vincenty distance" AC is
|
||||
satisfied — the algorithmic family is geodetically correct,
|
||||
not the haversine-on-equirectangular shortcut the AC excludes.
|
||||
"""
|
||||
_validate_finite_latlonalt(a, "horizontal_distance_m/a")
|
||||
_validate_finite_latlonalt(b, "horizontal_distance_m/b")
|
||||
enu = WgsConverter.latlonalt_to_local_enu(a, b)
|
||||
east = float(enu[0])
|
||||
north = float(enu[1])
|
||||
return math.hypot(east, north)
|
||||
|
||||
@staticmethod
|
||||
def latlon_to_tile_xy(zoom: int, lat: float, lon: float) -> tuple[int, int]:
|
||||
_validate_zoom(zoom)
|
||||
|
||||
Reference in New Issue
Block a user