Merge pull request 'feat(vessels): AISStream (US) + VesselAPI (Hormuz) as two active providers' (#19) from feat/vesselapi-ais-poller into master
All checks were successful
build-and-deploy / build-push-deploy (push) Successful in 2m51s

Reviewed-on: #19
This commit is contained in:
sirius 2026-08-29 07:23:37 -04:00
commit 7b48caa793
9 changed files with 866 additions and 5 deletions

View file

@ -59,6 +59,24 @@ FIRMS_INTERVAL=900
# Set to 0 to disable the fire loop entirely.
INGEST_FIRES=1
# ── VesselAPI (commercial REST AIS — Hormuz, 5×/day, 150 calls/mo cap) ─────
# Independent of AISStream (open/shared live US-coast WebSocket). Both stay
# on when their keys are set; missing one never disables the other.
# Prefer pasting VESSELAPI_API_KEY on the dashboard Keys page.
# The poller idles when the key is unset. Never called from map pans
# (GET /api/vessels serves the shared last-known cache only).
VESSELAPI_API_KEY=
# Bounding box(es) as minlat,minlon,maxlat,maxlon (lat/lon order). Semicolon-
# separated for multiple boxes. Default = Strait of Hormuz (span 3.6 ≤ 4° cap).
VESSELAPI_BBOX=25.5,55.4,27.3,57.2
# Poll cadence in seconds (17280 = 4.8h → 5 polls/day = 150/mo).
VESSELAPI_INTERVAL=17280
# Local hard cap on successful 2xx calls per UTC day (persisted in Postgres).
VESSELAPI_MAX_CALLS_PER_DAY=5
# 1 = run the poller inside the dashboard process (default); ingester off.
VESSELAPI_IN_APP=1
VESSELAPI_IN_INGEST=0
# ── API keys (managed from the dashboard UI) ──────────────────────────────
# Keys such as NOUS_API_KEY and TELEGRAM_TOKEN are stored in the Postgres
# `api_keys` table and managed from the dashboard's "Keys" tab

View file

@ -81,3 +81,19 @@ AISSTREAM_BBOX = os.getenv("AISSTREAM_BBOX", "24,-125,50,-66")
# without the ingest profile). Set 0 if the ingester owns the only connection.
AISSTREAM_IN_APP = os.getenv("AISSTREAM_IN_APP", "1").lower() in ("1", "true", "yes")
AISSTREAM_IN_INGEST = os.getenv("AISSTREAM_IN_INGEST", "0").lower() in ("1", "true", "yes")
# VesselAPI (quota-capped REST AIS poller — free tier 150 calls/mo).
# AISStream keeps US coasts; VesselAPI fills the Middle East blind spot. The
# poller idles when VESSELAPI_API_KEY is unset (never from GET /api/vessels).
VESSELAPI_API_KEY = os.getenv("VESSELAPI_API_KEY", "")
# Bounding box(es) as minlat,minlon,maxlat,maxlon — note lat/lon order (same as
# AISSTREAM_BBOX). Semicolon-separated for multiple boxes. Default: Strait of
# Hormuz (|dLat|+|dLon| = 3.6 ≤ 4° span cap). VesselAPI 400s any box over 4°.
VESSELAPI_BBOX = os.getenv("VESSELAPI_BBOX", "25.5,55.4,27.3,57.2")
# Poll cadence in seconds. 17280 = 4.8h → 5 polls/day (150/mo free tier).
VESSELAPI_INTERVAL = int(os.getenv("VESSELAPI_INTERVAL", "17280"))
# Local hard cap on successful 2xx calls per UTC day (persisted in Postgres).
VESSELAPI_MAX_CALLS_PER_DAY = int(os.getenv("VESSELAPI_MAX_CALLS_PER_DAY", "5"))
# Run the VesselAPI poller inside the dashboard process (default on, like AIS).
VESSELAPI_IN_APP = os.getenv("VESSELAPI_IN_APP", "1").lower() in ("1", "true", "yes")
VESSELAPI_IN_INGEST = os.getenv("VESSELAPI_IN_INGEST", "0").lower() in ("1", "true", "yes")

View file

@ -68,10 +68,15 @@ KEY_REGISTRY: dict[str, dict] = {
"example": "123456789:AA… (bot token from @BotFather)",
},
"AISSTREAM_API_KEY": {
"description": "AISStream WebSocket key — live vessel positions (server-side only).",
"description": "AISStream (open/shared) — live US-coast AIS. Server-side WebSocket only.",
"pattern": r"^.{8,}$",
"example": "key from https://aisstream.io/account (GitHub login)",
},
"VESSELAPI_API_KEY": {
"description": "VesselAPI (commercial) — Strait of Hormuz AIS, 5×/day cache. Paste the Bearer token from dashboard.vesselapi.com. Not a US-coast feed.",
"pattern": r"^.{8,}$",
"example": "Bearer token from https://dashboard.vesselapi.com/",
},
"OPENSKY_CLIENT_ID": {
"description": "OpenSky OAuth client id — optional ADS-B fallback (unused until enabled).",
"example": "client id from opensky-network.org account",

View file

@ -71,18 +71,26 @@ async def _lifespan(app: FastAPI):
await refresh_cache()
except Exception:
pass
from config import AISSTREAM_IN_APP
from config import AISSTREAM_IN_APP, VESSELAPI_IN_APP
ais_task = None
vesselapi_task = None
adsb_task = None
if AISSTREAM_IN_APP:
from ais_stream import run_ais_worker
ais_task = asyncio.create_task(run_ais_worker())
if VESSELAPI_IN_APP:
from vesselapi import run_vesselapi_worker
vesselapi_task = asyncio.create_task(run_vesselapi_worker())
adsb_task = asyncio.create_task(_adsb_refresh_loop())
yield
if ais_task is not None:
ais_task.cancel()
if vesselapi_task is not None:
vesselapi_task.cancel()
if adsb_task is not None:
adsb_task.cancel()
from vesselapi import close_client
await close_client()
await close_http()
@ -1464,7 +1472,12 @@ async def list_vessels(
limit: int = Query(2000, ge=1, le=5000),
timestamp: str | None = Query(None, description="ISO time — DVR 1-min tracks instead of live"),
):
"""AIS last-known from the server-side AISStream worker. Empty without a key."""
"""AIS last-known — union of two independent providers.
AISStream (extra.src="aisstream", live US-coast WebSocket) and VesselAPI
(extra.src="vesselapi", Strait of Hormuz 5×/day poll) both upsert into the
same store. Empty without either key / until the first successful poll.
"""
if bbox:
_parse_bbox_query(bbox)
try:

View file

@ -24,7 +24,7 @@ import sys
sys.path.insert(0, sys_path)
from config import NATS_URL, FIRMS_INTERVAL, FIRMS_DATASET, AISSTREAM_IN_INGEST # noqa: E402
from config import NATS_URL, FIRMS_INTERVAL, FIRMS_DATASET, AISSTREAM_IN_INGEST, VESSELAPI_IN_INGEST # noqa: E402
from sources import ingest_rss_feed, ingest_gdelt, ingest_earthquakes, ingest_eonet, ingest_cisa_kev # noqa: E402
from fire_sources import ingest_fires # noqa: E402
from ingestor import ingest_event, start_nats_consumer # noqa: E402
@ -134,6 +134,9 @@ async def main() -> None:
if AISSTREAM_IN_INGEST:
from ais_stream import run_ais_worker # noqa: E402
tasks.append(asyncio.create_task(run_ais_worker()))
if VESSELAPI_IN_INGEST:
from vesselapi import run_vesselapi_worker # noqa: E402
tasks.append(asyncio.create_task(run_vesselapi_worker()))
await asyncio.gather(producer_loop(), consumer_loop(), *tasks)

View file

@ -805,7 +805,7 @@
<label class="lp-name"><input type="checkbox" id="lp-vessels-on" onchange="toggleVessels()"> <span class="lp-dot vessels"></span> Vessels · AIS</label>
<span class="lp-count" id="lp-vessels-count">0</span>
</div>
<div class="lp-note">Needs AISSTREAM_API_KEY. Magenta = military · gold = law/gov · teal = underway. Click for type / dest / IMO.</div>
<div class="lp-note">Two feeds: AISStream (US live) and VesselAPI (Hormuz, 5×/day). Paste both keys on the Keys page. Magenta = military · gold = law/gov · teal = underway. Click for type / dest / IMO.</div>
</div>
<div class="lp-layer">
<div class="lp-row">

445
app/vesselapi.py Normal file
View file

@ -0,0 +1,445 @@
"""VesselAPI REST poller — quota-capped AIS for the Middle East (free tier 150 calls/mo).
VesselAPI and AISStream are two independent, first-class vessel providers
not a primary/fallback pair. AISStream (WebSocket) owns live US-coast AIS;
VesselAPI (REST) covers the Strait of Hormuz (default box) where AISStream
has no coverage. Missing one key never disables the other. This worker polls
the REST ``GET /v1/location/vessels/bounding-box`` endpoint at most
``VESSELAPI_MAX_CALLS_PER_DAY`` (default 5) *successful 2xx* calls per UTC day
and upserts the results into the shared ``vessel_last_known`` store.
Idle (no crash) when VESSELAPI_API_KEY is unset. Never called from the GET
/api/vessels path map pans must not hit upstream. One request per poll,
``pagination.limit=50``, never follow ``nextToken``, never send ``filter.sat``.
"""
from __future__ import annotations
import asyncio
import calendar
import logging
import os
from datetime import date, datetime, timezone
import httpx
from sqlalchemy import Column, Date, DateTime, Integer, Table, func, select, text
from config import (
OSINT_USER_AGENT,
VESSELAPI_API_KEY,
VESSELAPI_BBOX,
VESSELAPI_INTERVAL,
VESSELAPI_MAX_CALLS_PER_DAY,
)
from database import async_session, engine, metadata
from live_layers import to_marker, upsert_vessel
logger = logging.getLogger("osint.vesselapi")
BASE_URL = "https://api.vesselapi.com/v1"
ENDPOINT = f"{BASE_URL}/location/vessels/bounding-box"
MAX_SPAN_DEG = 4.0 # |dLat| + |dLon| — VesselAPI 400s above this.
PAGE_LIMIT = 50 # pagination.limit; never follow nextToken on the free tier.
_client: httpx.AsyncClient | None = None
_client_lock = asyncio.Lock()
# ── Box parsing / span validation ─────────────────────────────────────────
class BboxError(ValueError):
"""A configured VesselAPI box violates the 4° span rule or is malformed."""
def validate_bbox_span(
minlat: float, minlon: float, maxlat: float, maxlon: float,
) -> None:
"""Reject boxes VesselAPI would 400 on (span > 4°, bad order, bad range)."""
if not (-90 <= minlat <= 90 and -90 <= maxlat <= 90
and -180 <= minlon <= 180 and -180 <= maxlon <= 180):
raise BboxError("coordinates out of range")
if minlat >= maxlat or minlon >= maxlon:
raise BboxError("bbox must have min < max on both axes")
dlat = abs(maxlat - minlat)
dlon = abs(maxlon - minlon)
if dlat + dlon > MAX_SPAN_DEG:
raise BboxError(
f"span |dLat|+|dLon| = {dlat + dlon:.2f}° exceeds {MAX_SPAN_DEG}° cap"
)
def parse_boxes(raw: str) -> list[tuple[float, float, float, float]]:
"""Env format: ``minlat,minlon,maxlat,maxlon[; ...]`` (lat/lon order)."""
out: list[tuple[float, float, float, float]] = []
for chunk in (raw or "").split(";"):
parts = [p.strip() for p in chunk.split(",") if p.strip()]
if len(parts) != 4:
continue
try:
minlat = float(parts[0])
minlon = float(parts[1])
maxlat = float(parts[2])
maxlon = float(parts[3])
except ValueError:
continue
out.append((minlat, minlon, maxlat, maxlon))
return out
def parse_boxes_validated(raw: str) -> list[tuple[float, float, float, float]]:
"""Parse boxes, log + skip any that violate the span/order/range rules."""
valid: list[tuple[float, float, float, float]] = []
for box in parse_boxes(raw):
try:
validate_bbox_span(*box)
valid.append(box)
except BboxError as exc:
logger.warning("VesselAPI bbox %r skipped: %s", box, exc)
return valid
# ── Position → marker transform ───────────────────────────────────────────
def _f(value: object) -> float | None:
if value is None or value == "":
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _s(value: object) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
def transform_vesselapi_position(obj: dict | None) -> dict | None:
"""Map one VesselAPI position object to the shared marker contract.
Returns None for glitch rows, missing MMSI, or missing coordinates.
"""
if not obj or not isinstance(obj, dict):
return None
if obj.get("suspected_glitch") is True:
return None
mmsi = obj.get("mmsi")
if mmsi is None:
return None
lat = _f(obj.get("latitude"))
lon = _f(obj.get("longitude"))
if lat is None or lon is None:
return None
mmsi_s = str(mmsi)
name = _s(obj.get("vessel_name") or obj.get("name"))
heading = _f(obj.get("heading"))
if heading is None:
heading = _f(obj.get("cog"))
sog = _f(obj.get("sog"))
extra: dict = {
"src": "vesselapi",
"mmsi": mmsi_s,
"cog": obj.get("cog"),
"sog": obj.get("sog"),
"navstat": obj.get("nav_status"),
}
imo = obj.get("imo")
if imo:
extra["imo"] = imo
dest = _s(obj.get("dest") or obj.get("destination"))
if dest:
extra["dest"] = dest
ts = obj.get("timestamp") or obj.get("processed_timestamp")
if ts:
extra["timestamp"] = ts
return to_marker(
mmsi_s, lat, lon,
heading=heading,
speed=sog,
label=name or mmsi_s,
extra=extra,
)
def transform_vesselapi_payload(payload: dict | None) -> list[dict]:
"""Flatten a bounding-box response ``{vessels: [...]}`` to markers."""
if not payload or not isinstance(payload, dict):
return []
rows = payload.get("vessels") or []
out = []
for row in rows:
marker = transform_vesselapi_position(row)
if marker:
out.append(marker)
return out
# ── Durable daily quota (Postgres, survives restarts) ─────────────────────
# Mirrors keystore.api_keys: lazy CREATE TABLE IF NOT EXISTS, no alembic fork.
vesselapi_quota = Table(
"vesselapi_quota",
metadata,
Column("day", Date, primary_key=True),
Column("calls", Integer, nullable=False, server_default="0"),
Column("remaining", Integer, nullable=True),
Column("updated_at", DateTime(timezone=True), server_default=func.now(), nullable=False),
)
_CREATE_QUOTA_SQL = text(
"""
CREATE TABLE IF NOT EXISTS vesselapi_quota (
day DATE PRIMARY KEY,
calls INTEGER NOT NULL DEFAULT 0,
remaining INTEGER,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""
)
_quota_lock = asyncio.Lock()
_quota_ensured = False
async def ensure_quota_table() -> None:
global _quota_ensured
if _quota_ensured:
return
async with _quota_lock:
if _quota_ensured:
return
async with engine.begin() as conn:
await conn.execute(_CREATE_QUOTA_SQL)
_quota_ensured = True
class PgQuotaStore:
"""Postgres-backed daily call counter. Injected for tests."""
async def calls_today(self, day: date) -> int:
await ensure_quota_table()
async with async_session() as session:
row = (await session.execute(
select(vesselapi_quota.c.calls).where(vesselapi_quota.c.day == day)
)).scalar()
return int(row) if row else 0
async def remaining_today(self, day: date) -> int | None:
await ensure_quota_table()
async with async_session() as session:
row = (await session.execute(
select(vesselapi_quota.c.remaining).where(vesselapi_quota.c.day == day)
)).scalar()
return int(row) if row is not None else None
async def bump(self, day: date, remaining: int | None) -> int:
await ensure_quota_table()
now = datetime.now(timezone.utc)
async with async_session() as session:
existing = (await session.execute(
select(vesselapi_quota.c.calls).where(vesselapi_quota.c.day == day)
)).scalar()
if existing is None:
await session.execute(
vesselapi_quota.insert().values(
day=day, calls=1, remaining=remaining, updated_at=now,
)
)
else:
await session.execute(
vesselapi_quota.update()
.where(vesselapi_quota.c.day == day)
.values(
calls=vesselapi_quota.c.calls + 1,
remaining=remaining,
updated_at=now,
)
)
await session.commit()
return (int(existing) if existing else 0) + 1
# ── Budget / scheduling (pure, unit-testable) ─────────────────────────────
def days_left_in_month(now: datetime) -> int:
"""UTC days remaining in the current month, inclusive of today."""
_, last = calendar.monthrange(now.year, now.month)
return last - now.day + 1
def budget_allows(
calls_today: int,
remaining: int | None,
days_left: int,
max_per_day: int,
) -> bool:
"""True if another poll is permitted today.
Local hard cap: fewer than ``max_per_day`` successful calls today.
Monthly floor: if ``X-RateLimit-Remaining`` is known, keep at least
``max_per_day * days_left`` in reserve for the rest of the month.
"""
if calls_today >= max_per_day:
return False
if remaining is not None and remaining <= max_per_day * days_left:
return False
return True
def choose_box(
boxes: list[tuple[float, float, float, float]],
calls_today: int,
max_per_day: int,
) -> int:
"""Index into ``boxes`` for the next poll.
Prefer refreshing the first (primary) box rather than spraying one call
across every region round-robin only when the remaining daily budget is
enough to cover all boxes.
"""
if len(boxes) <= 1:
return 0
budget_left = max_per_day - calls_today
if budget_left >= len(boxes):
return calls_today % len(boxes)
return 0
# ── HTTP / poll ───────────────────────────────────────────────────────────
async def _resolve_key() -> str:
from keystore import get_api_key
return (
os.getenv("VESSELAPI_API_KEY")
or VESSELAPI_API_KEY
or (await get_api_key("VESSELAPI_API_KEY"))
or ""
).strip()
async def _get_client() -> httpx.AsyncClient:
global _client
if _client is None:
async with _client_lock:
if _client is None:
_client = httpx.AsyncClient(
timeout=httpx.Timeout(15.0, connect=5.0),
follow_redirects=True,
headers={"User-Agent": OSINT_USER_AGENT, "Accept": "application/json"},
limits=httpx.Limits(max_connections=1, max_keepalive_connections=1),
)
return _client
async def close_client() -> None:
global _client
if _client is not None:
await _client.aclose()
_client = None
def _int_header(value: str | None) -> int | None:
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
async def poll_once(store, boxes: list[tuple[float, float, float, float]], key: str) -> bool:
"""One quota-checked poll. Returns True if a successful 2xx was made.
Only successful 2xx responses count against the monthly quota; 4xx/5xx/429
are skipped without retry-storming (Retry-After respected by simply
sleeping the interval).
"""
now = datetime.now(timezone.utc)
today = now.date()
calls = await store.calls_today(today)
remaining = await store.remaining_today(today)
days_left = days_left_in_month(now)
if not budget_allows(calls, remaining, days_left, VESSELAPI_MAX_CALLS_PER_DAY):
logger.info(
"VesselAPI quota reached (calls_today=%d, remaining=%s, days_left=%d) — skip poll",
calls, remaining, days_left,
)
return False
idx = choose_box(boxes, calls, VESSELAPI_MAX_CALLS_PER_DAY)
minlat, minlon, maxlat, maxlon = boxes[idx]
client = await _get_client()
params = {
"filter.latBottom": str(minlat),
"filter.latTop": str(maxlat),
"filter.lonLeft": str(minlon),
"filter.lonRight": str(maxlon),
"pagination.limit": str(PAGE_LIMIT),
}
headers = {"Authorization": f"Bearer {key}"}
try:
resp = await client.get(ENDPOINT, params=params, headers=headers)
except httpx.HTTPError as exc:
logger.warning("VesselAPI request failed: %s", exc)
return False
if resp.status_code == 429:
logger.warning(
"VesselAPI rate-limited (Retry-After=%s) — skip poll",
resp.headers.get("Retry-After"),
)
return False
if resp.status_code >= 400:
logger.warning("VesselAPI HTTP %d — not counted against quota", resp.status_code)
return False
# 2xx success — counts against the monthly quota.
remaining = _int_header(resp.headers.get("X-RateLimit-Remaining"))
calls = await store.bump(today, remaining)
try:
data = resp.json()
except ValueError:
logger.warning("VesselAPI 2xx with non-JSON body — counted but ignored")
return True
markers = transform_vesselapi_payload(data)
for m in markers:
await upsert_vessel(m)
logger.info(
"VesselAPI poll OK: %d vessels (remaining=%s, calls_today=%d)",
len(markers), remaining, calls,
)
return True
# ── Worker loop ───────────────────────────────────────────────────────────
async def run_vesselapi_worker(store: PgQuotaStore | None = None) -> None:
"""Long-lived poll loop. Idle when the key is unset; never crashes the app."""
if store is None:
store = PgQuotaStore()
boxes = parse_boxes_validated(VESSELAPI_BBOX)
if not boxes:
logger.warning(
"VESSELAPI_BBOX has no valid boxes (span ≤ %.1f°) — poller idle", MAX_SPAN_DEG,
)
while True:
try:
if not boxes:
await asyncio.sleep(VESSELAPI_INTERVAL)
continue
key = await _resolve_key()
if not key:
logger.warning(
"VESSELAPI_API_KEY not set — VesselAPI poller idle. "
"Create a free key at https://dashboard.vesselapi.com/"
)
await asyncio.sleep(VESSELAPI_INTERVAL)
continue
await poll_once(store, boxes, key)
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001 — keep the loop alive across transient failures
logger.exception("VesselAPI poll error")
await asyncio.sleep(VESSELAPI_INTERVAL)

View file

@ -97,6 +97,11 @@ services:
AISSTREAM_API_KEY: ${AISSTREAM_API_KEY:-}
AISSTREAM_BBOX: ${AISSTREAM_BBOX:-24,-125,50,-66}
AISSTREAM_IN_INGEST: ${AISSTREAM_IN_INGEST:-0}
VESSELAPI_API_KEY: ${VESSELAPI_API_KEY:-}
VESSELAPI_BBOX: ${VESSELAPI_BBOX:-25.5,55.4,27.3,57.2}
VESSELAPI_INTERVAL: ${VESSELAPI_INTERVAL:-17280}
VESSELAPI_MAX_CALLS_PER_DAY: ${VESSELAPI_MAX_CALLS_PER_DAY:-5}
VESSELAPI_IN_INGEST: ${VESSELAPI_IN_INGEST:-0}
command: ["python", "app/run_ingester.py"]
entrypoint: ["python", "app/run_ingester.py"]
@ -131,6 +136,11 @@ services:
AISSTREAM_API_KEY: ${AISSTREAM_API_KEY:-}
AISSTREAM_BBOX: ${AISSTREAM_BBOX:-24,-125,50,-66}
AISSTREAM_IN_APP: ${AISSTREAM_IN_APP:-1}
VESSELAPI_API_KEY: ${VESSELAPI_API_KEY:-}
VESSELAPI_BBOX: ${VESSELAPI_BBOX:-25.5,55.4,27.3,57.2}
VESSELAPI_INTERVAL: ${VESSELAPI_INTERVAL:-17280}
VESSELAPI_MAX_CALLS_PER_DAY: ${VESSELAPI_MAX_CALLS_PER_DAY:-5}
VESSELAPI_IN_APP: ${VESSELAPI_IN_APP:-1}
ports:
- "127.0.0.1:8000:8000"
deploy:

351
tests/test_vesselapi.py Normal file
View file

@ -0,0 +1,351 @@
"""Unit tests for the VesselAPI poller (no network, no DB).
Covers box span validation, position marker mapping, glitch skipping,
and the daily-quota gate (6th 2xx attempt skipped). ``poll_once`` is driven
with an in-memory fake quota store + fake HTTP client; ``upsert_vessel``'s
DB/WS side effects are monkeypatched to no-ops so markers can be asserted in
``vessel_last_known``.
"""
from __future__ import annotations
import asyncio
from datetime import datetime, timezone
import pytest
import vesselapi
from vesselapi import (
BboxError,
budget_allows,
choose_box,
days_left_in_month,
parse_boxes,
parse_boxes_validated,
poll_once,
transform_vesselapi_payload,
transform_vesselapi_position,
validate_bbox_span,
)
from live_layers import vessel_last_known
# ── Fake quota store (in-memory, injected) ────────────────────────────────
class FakeStore:
def __init__(self, calls: int = 0, remaining: int | None = None):
self.calls = calls
self.remaining = remaining
self.bumps = 0
async def calls_today(self, day):
return self.calls
async def remaining_today(self, day):
return self.remaining
async def bump(self, day, remaining):
self.calls += 1
self.remaining = remaining
self.bumps += 1
return self.calls
# ── Fake HTTP client ───────────────────────────────────────────────────────
class FakeResp:
def __init__(self, status: int = 200, headers: dict | None = None, data: dict | None = None):
self.status_code = status
self.headers = headers or {}
self._data = data or {}
def json(self):
return self._data
class FakeClient:
def __init__(self, *responses: FakeResp):
self.responses = list(responses)
self.calls: list[tuple] = []
async def get(self, url, params=None, headers=None):
self.calls.append((url, params, headers))
return self.responses.pop(0)
async def aclose(self):
pass
def _sample_payload() -> dict:
return {
"vessels": [
{
"mmsi": 422050100,
"imo": 9321483,
"vessel_name": "HORMUZ STAR",
"latitude": 26.5,
"longitude": 56.3,
"cog": 88.0,
"sog": 12.4,
"heading": 90,
"nav_status": 0,
"timestamp": "2026-08-29T12:00:00Z",
"suspected_glitch": False,
},
{
"mmsi": 422050101,
"vessel_name": "GLITCHY",
"latitude": 26.6,
"longitude": 56.4,
"cog": 45.0,
"sog": 5.0,
"heading": 45,
"nav_status": 5,
"suspected_glitch": True,
},
{"mmsi": 422050102, "vessel_name": "NOFIX"}, # no coords → drop
],
"nextToken": "deadbeef",
}
# ── Box span / parsing ─────────────────────────────────────────────────────
def test_validate_bbox_span_accepts_hormuz():
validate_bbox_span(25.5, 55.4, 27.3, 57.2) # span 3.6 — no raise
def test_validate_bbox_span_rejects_conus():
with pytest.raises(BboxError):
validate_bbox_span(24.0, -125.0, 50.0, -66.0) # span 85
def test_validate_bbox_span_rejects_marine_regions_gazetteer_box():
# 25.273227.3713 N, 55.164757.3419 E → span 4.28 > 4.0.
with pytest.raises(BboxError):
validate_bbox_span(25.2732, 55.1647, 27.3713, 57.3419)
def test_validate_bbox_span_rejects_inverted_axes():
with pytest.raises(BboxError):
validate_bbox_span(27.0, 55.0, 25.0, 57.0)
def test_parse_boxes_semicolon_and_skip_malformed():
boxes = parse_boxes("25.5,55.4,27.3,57.2; 10,20,11,21; garbage")
assert boxes == [(25.5, 55.4, 27.3, 57.2), (10.0, 20.0, 11.0, 21.0)]
def test_parse_boxes_validated_skips_over_span():
# Second box is CONUS-sized → dropped, first kept.
valid = parse_boxes_validated("25.5,55.4,27.3,57.2;24,-125,50,-66")
assert valid == [(25.5, 55.4, 27.3, 57.2)]
# ── Position → marker mapping ─────────────────────────────────────────────
def test_transform_position_maps_shared_marker_contract():
m = transform_vesselapi_position({
"mmsi": 422050100, "imo": 9321483, "vessel_name": "HORMUZ STAR",
"latitude": 26.5, "longitude": 56.3, "heading": 90, "cog": 88.0,
"sog": 12.4, "nav_status": 0, "timestamp": "2026-08-29T12:00:00Z",
"suspected_glitch": False,
})
assert m is not None
assert m["id"] == "422050100"
assert m["lat"] == 26.5
assert m["lon"] == 56.3
assert m["label"] == "HORMUZ STAR"
assert m["heading"] == 90
assert m["speed"] == 12.4
assert m["extra"]["src"] == "vesselapi"
assert m["extra"]["mmsi"] == "422050100"
assert m["extra"]["imo"] == 9321483
assert m["extra"]["navstat"] == 0
assert m["extra"]["cog"] == 88.0
assert m["extra"]["sog"] == 12.4
assert m["extra"]["timestamp"] == "2026-08-29T12:00:00Z"
def test_transform_position_heading_falls_back_to_cog():
m = transform_vesselapi_position({
"mmsi": 123456789, "vessel_name": "X", "latitude": 1.0, "longitude": 2.0,
"heading": None, "cog": 123.4, "sog": 5.0,
})
assert m["heading"] == 123.4
def test_transform_position_skips_glitch():
assert transform_vesselapi_position({
"mmsi": 123456789, "latitude": 1.0, "longitude": 2.0,
"suspected_glitch": True,
}) is None
def test_transform_position_skips_missing_coords():
assert transform_vesselapi_position({"mmsi": 123456789, "vessel_name": "NOFIX"}) is None
def test_transform_payload_skips_glitch_and_nofix_rows():
rows = transform_vesselapi_payload(_sample_payload())
assert [r["id"] for r in rows] == ["422050100"]
# ── Quota budget / scheduling ─────────────────────────────────────────────
def test_days_left_in_month():
assert days_left_in_month(datetime(2026, 8, 29, tzinfo=timezone.utc)) == 3
def test_budget_allows_local_daily_cap():
# 5 calls already made → 6th is blocked regardless of remaining.
assert budget_allows(5, remaining=1000, days_left=3, max_per_day=5) is False
def test_budget_allows_monthly_floor():
# remaining 14 ≤ 5*3=15 → skip; 16 > 15 → allow.
assert budget_allows(2, remaining=14, days_left=3, max_per_day=5) is False
assert budget_allows(2, remaining=16, days_left=3, max_per_day=5) is True
def test_budget_allows_unknown_remaining():
assert budget_allows(2, remaining=None, days_left=3, max_per_day=5) is True
def test_choose_box_prefers_primary_when_budget_tight():
boxes = [(1, 1, 2, 2), (3, 3, 4, 4), (5, 5, 6, 6)]
# 4 calls made, 1 left → always box 0.
assert choose_box(boxes, 4, max_per_day=5) == 0
def test_choose_box_round_robins_when_budget_covers_all():
boxes = [(1, 1, 2, 2), (3, 3, 4, 4), (5, 5, 6, 6)]
# 0 calls made, 5 left ≥ 3 boxes → round-robin.
assert choose_box(boxes, 0, max_per_day=5) == 0
assert choose_box(boxes, 1, max_per_day=5) == 1
assert choose_box(boxes, 2, max_per_day=5) == 2
# ── poll_once integration (fake store + fake client) ─────────────────────
def _patch_side_effects(monkeypatch):
async def _noop(*a, **k):
return None
monkeypatch.setattr("tracks.record_position", _noop)
monkeypatch.setattr("geofence.record_and_notify", _noop)
def test_poll_once_lands_markers_in_vessel_last_known(monkeypatch):
_patch_side_effects(monkeypatch)
vessel_last_known.clear()
client = FakeClient(
FakeResp(200, {"X-RateLimit-Remaining": "140"}, _sample_payload()),
)
monkeypatch.setattr(vesselapi, "_get_client", _make_get_client(client))
store = FakeStore()
ok = asyncio.run(poll_once(store, [(25.5, 55.4, 27.3, 57.2)], "test-key"))
assert ok is True
assert store.calls == 1
assert "422050100" in vessel_last_known
assert vessel_last_known["422050100"]["extra"]["src"] == "vesselapi"
assert "422050101" not in vessel_last_known # glitch skipped
# One HTTP call, bounding-box params, no sat / no nextToken follow.
assert len(client.calls) == 1
_url, params, headers = client.calls[0]
assert params["filter.latBottom"] == "25.5"
assert params["filter.latTop"] == "27.3"
assert params["filter.lonLeft"] == "55.4"
assert params["filter.lonRight"] == "57.2"
assert params["pagination.limit"] == "50"
assert "sat" not in params
assert headers["Authorization"] == "Bearer test-key"
def test_poll_once_sixth_2xx_is_skipped_with_zero_http(monkeypatch):
client = FakeClient()
monkeypatch.setattr(vesselapi, "_get_client", _make_get_client(client))
# 5 successful calls already today → 6th poll makes no HTTP request.
store = FakeStore(calls=5, remaining=1000)
ok = asyncio.run(poll_once(store, [(25.5, 55.4, 27.3, 57.2)], "test-key"))
assert ok is False
assert store.bumps == 0
assert client.calls == []
def test_poll_once_4xx_not_counted_and_no_upsert(monkeypatch):
_patch_side_effects(monkeypatch)
vessel_last_known.clear()
client = FakeClient(FakeResp(400, {}, {"error": {}}))
monkeypatch.setattr(vesselapi, "_get_client", _make_get_client(client))
store = FakeStore()
ok = asyncio.run(poll_once(store, [(25.5, 55.4, 27.3, 57.2)], "test-key"))
assert ok is False
assert store.bumps == 0 # 4xx does not count against quota
assert vessel_last_known == {}
def _make_get_client(client):
async def _get_client():
return client
return _get_client
# ── Independent-provider idle behaviour ───────────────────────────────────
# VesselAPI must never be gated on AISStream (or vice versa): a missing key on
# one provider leaves the other running. ``_StopLoop`` is a BaseException so
# the worker's ``except Exception`` handler can't swallow it — the first
# ``asyncio.sleep`` aborts the loop after exactly one decision.
class _StopLoop(BaseException):
pass
async def _stop_sleep(*_a, **_k):
raise _StopLoop()
async def _resolve(value: str):
return value
def test_worker_idles_without_vesselapi_key_even_if_aisstream_set(monkeypatch):
# AISStream key present, VesselAPI key absent -> no poll, still idles.
monkeypatch.setenv("AISSTREAM_API_KEY", "unused-aisstream-key")
monkeypatch.setenv("VESSELAPI_API_KEY", "")
monkeypatch.setattr(vesselapi, "_resolve_key", lambda: _resolve(""))
monkeypatch.setattr(vesselapi.asyncio, "sleep", _stop_sleep)
poll_calls: list = []
async def _spy_poll(store, boxes, key):
poll_calls.append(key)
return True
monkeypatch.setattr(vesselapi, "poll_once", _spy_poll)
with pytest.raises(_StopLoop):
asyncio.run(vesselapi.run_vesselapi_worker(FakeStore()))
assert poll_calls == []
def test_worker_polls_with_vesselapi_key_even_if_aisstream_unset(monkeypatch):
# AISStream key absent, VesselAPI key present -> still polls exactly once.
monkeypatch.setenv("AISSTREAM_API_KEY", "")
monkeypatch.setattr(vesselapi, "_resolve_key", lambda: _resolve("vesselapi-key"))
monkeypatch.setattr(vesselapi.asyncio, "sleep", _stop_sleep)
poll_calls: list = []
async def _spy_poll(store, boxes, key):
poll_calls.append(key)
return True
monkeypatch.setattr(vesselapi, "poll_once", _spy_poll)
with pytest.raises(_StopLoop):
asyncio.run(vesselapi.run_vesselapi_worker(FakeStore()))
assert poll_calls == ["vesselapi-key"]