"""Ingest pipeline: NDTP fixes and replayed rows become ``Ping`` objects on one timeline, in one stream.
Both sources feed the same pipeline, arbitrated per vehicle: while a vehicle has sent NDTP within
``ndtp_fresh_s``, replayed rows for it are dropped. When its live feed goes quiet, replay takes over;
when NDTP returns, it takes back over (criterion 5: degrade to historical data, recover after reconnect).
"""
from __future__ import annotations
import asyncio
import logging
import time
from collections.abc import Callable
from dataclasses import asdict, dataclass, replace
from ..clock import DatasetClock
from ..ndtp import NdtpFix
from .models import Ping
log = logging.getLogger(__name__)
Subscriber = Callable[[Ping], None]
[документация]
@dataclass(slots=True)
class IngestStats:
ndtp_pings: int = 0
replay_pings: int = 0
replay_suppressed: int = 0 # replayed rows dropped because the vehicle is live on NDTP
unknown_unit_fixes: int = 0 # NDTP fixes from units not in the registry
clock_skew_fallbacks: int = 0 # terminal clock too far off: server receive time used instead
subscriber_errors: int = 0
[документация]
class Ingest:
[документация]
def __init__(self, clock: DatasetClock, unit_to_tr: dict[int, int], *, ndtp_fresh_s: float = 60.0,
max_skew_s: float = 300.0, wall: Callable[[], float] = time.time) -> None:
self.clock = clock
self.unit_to_tr = unit_to_tr
self.ndtp_fresh_s = ndtp_fresh_s
self.max_skew_s = max_skew_s
self.stats = IngestStats()
self.latest: dict[int, Ping] = {} # tr_id → newest ping by event_time
self.unknown_units: dict[int, int] = {} # unit_id → fixes received
self._ndtp_seen: dict[int, float] = {} # tr_id → wall time of its last NDTP ping
self._subscribers: list[Subscriber] = []
self._wall = wall
[документация]
def subscribe(self, fn: Subscriber) -> None:
self._subscribers.append(fn)
# ------------------------------------------------------------------ sources
[документация]
def accept_ndtp(self, fix: NdtpFix) -> Ping | None:
tr_id = self.unit_to_tr.get(fix.unit_id)
if tr_id is None:
self.stats.unknown_unit_fixes += 1
self.unknown_units[fix.unit_id] = self.unknown_units.get(fix.unit_id, 0) + 1
return None
nav = fix.nav
event_wall = float(nav.timestamp)
if abs(fix.received_at - event_wall) > self.max_skew_s:
self.stats.clock_skew_fallbacks += 1
event_wall = fix.received_at
valid = nav.location_valid
ping = Ping(
tr_id=tr_id,
unit_id=fix.unit_id,
event_time=self.clock.to_dataset(event_wall),
lat=nav.latitude if valid else None,
lon=nav.longitude if valid else None,
speed_kmh=float(nav.speed_avg_kmh) if valid else None,
heading_deg=float(nav.course_deg) if valid else None,
location_valid=valid,
is_hist=False,
source="ndtp",
received_at=fix.received_at,
)
self._ndtp_seen[tr_id] = fix.received_at
self.stats.ndtp_pings += 1
self._emit(ping)
return ping
[документация]
def accept_replay(self, ping: Ping) -> bool:
now = self._wall()
seen = self._ndtp_seen.get(ping.tr_id)
if seen is not None and now - seen < self.ndtp_fresh_s:
self.stats.replay_suppressed += 1
return False
self.stats.replay_pings += 1
self._emit(replace(ping, received_at=now))
return True
[документация]
async def consume_ndtp(self, fixes: asyncio.Queue[NdtpFix]) -> None:
while True:
self.accept_ndtp(await fixes.get())
# ------------------------------------------------------------------ output
def _emit(self, ping: Ping) -> None:
prev = self.latest.get(ping.tr_id)
if prev is None or ping.event_time >= prev.event_time:
self.latest[ping.tr_id] = ping
for fn in self._subscribers:
try:
fn(ping)
except Exception: # noqa: BLE001 — one broken consumer must not stop ingest
self.stats.subscriber_errors += 1
log.exception("ingest subscriber %r failed", fn)
# ------------------------------------------------------------------ introspection
[документация]
def live_vehicles(self) -> list[int]:
"""Vehicles currently fed by NDTP (not replay)."""
now = self._wall()
return sorted(tr for tr, seen in self._ndtp_seen.items() if now - seen < self.ndtp_fresh_s)
[документация]
def snapshot(self) -> dict:
return {
"stats": asdict(self.stats),
"vehicles_seen": len(self.latest),
"vehicles_live_ndtp": self.live_vehicles(),
"registry_units": len(self.unit_to_tr),
"unknown_units": dict(sorted(self.unknown_units.items(), key=lambda kv: -kv[1])[:50]),
}