SmartestHome/digest-engine/ingest/naval_traffic.py

249 lines
9.4 KiB
Python

"""Naval/AIS ingestion — aisstream.io, sampled for a few seconds per run.
Additional analysis input for the political section, tagged
`"category": "naval_traffic"`. Not a section of its own.
What this can and cannot do, stated plainly because it is easy to get wrong:
AIS is a collision-avoidance transponder, not a surveillance system. Warships,
auxiliaries and anything else with a reason to be discreet sail with AIS off or
spoofed as a matter of routine, and the AIS "military ops" ship-type code is
self-declared and almost never set by an actual combatant. **This module cannot
detect a naval concentration and must never be read as if it could.** What it can
measure is merchant traffic, and merchant traffic *withdrawing* from a chokepoint
— the Red Sea transit collapse being the obvious case — is a real signal that
pairs with the freight-cost, insurance and crude-price material the financial and
news sources already supply. That, and only that, is why this exists.
Sourcing, all verified 2026-07-28:
aisstream.io is the only genuinely free real-time AIS source found. A free
account (GitHub sign-in) yields an API key; the key is the sole credential and
is sent inside the subscription payload, not an HTTP header. It is WebSocket-
only — there is no REST endpoint — which is why this module samples a short
window rather than making a request. It is self-described as beta with no
uptime SLA, so treat a run that returns nothing as normal rather than broken.
A global subscription can push ~300 messages/second, so the bounding boxes
below are not optional.
AISHub remains contribute-to-access: you must stream raw NMEA from your own
AIS receiver to their UDP endpoint and meet coverage/uptime thresholds before
you get API credentials. That needs physical hardware this project does not
have, so it is not an option here.
MarineTraffic, VesselFinder and Spire are commercial/paid. Not integrated.
Configuration is env-only:
AISSTREAM_API_KEY — required; free, from https://aisstream.io after sign-in.
NAVAL_REGIONS — semicolon-separated `Name:lamin,lomin,lamax,lomax` boxes,
same format as FLIGHT_REGIONS. Defaults to the maritime
chokepoints, which is where the signal is.
NAVAL_SAMPLE_SECONDS — how long to hold the socket open. The default is
deliberately short: this is a traffic-density sample, not
a census, and a oneshot digest should not sit on a socket.
"""
import json
import logging
import os
import time
from datetime import datetime, timezone
LOG = logging.getLogger(__name__)
STREAM_URL = "wss://stream.aisstream.io/v0/stream"
DEFAULT_REGIONS = (
"Red Sea / Bab el-Mandeb:12,38,20,45;"
"Strait of Hormuz:24,54,28,58;"
"Black Sea:41,27,47,42;"
"Taiwan Strait:21,117,27,124;"
"Suez Canal approaches:29,32,32,34"
)
# AIS ship-type code ranges, ITU-R M.1371. Coarse on purpose — the useful question
# is "what kind of trade is moving through here", not the exact hull category.
_TYPE_BUCKETS = (
(80, 89, "tanker"),
(70, 79, "cargo"),
(60, 69, "passenger"),
(40, 49, "high_speed_craft"),
(30, 30, "fishing"),
(35, 35, "self_declared_military_ops"),
(55, 55, "self_declared_law_enforcement"),
)
def _parse_regions(raw):
regions = []
for chunk in raw.split(";"):
chunk = chunk.strip()
if not chunk:
continue
try:
name, box = chunk.rsplit(":", 1)
lamin, lomin, lamax, lomax = (float(part) for part in box.split(","))
regions.append(
{
"name": name.strip(),
"lamin": lamin,
"lomin": lomin,
"lamax": lamax,
"lomax": lomax,
}
)
except Exception:
LOG.warning("naval_traffic: could not parse region %r, skipping", chunk, exc_info=True)
return regions
def _bucket(type_code):
for low, high, label in _TYPE_BUCKETS:
if low <= type_code <= high:
return label
return "other"
def _region_for(regions, lat, lon):
for region in regions:
if region["lamin"] <= lat <= region["lamax"] and region["lomin"] <= lon <= region["lomax"]:
return region["name"]
return None
def _sample(socket, regions, deadline):
seen = {region["name"]: set() for region in regions}
types = {region["name"]: {} for region in regions}
names = {region["name"]: {} for region in regions}
received = 0
while time.monotonic() < deadline:
try:
raw = socket.recv()
except Exception:
# A recv timeout inside the sampling window is ordinary on a quiet box;
# anything worse is caught by the caller.
break
if not raw:
continue
try:
message = json.loads(raw)
except ValueError:
continue
received += 1
metadata = message.get("MetaData") or {}
lat = metadata.get("latitude")
lon = metadata.get("longitude")
mmsi = metadata.get("MMSI")
if lat is None or lon is None or mmsi is None:
continue
region_name = _region_for(regions, lat, lon)
if region_name is None:
continue
seen[region_name].add(mmsi)
static = (message.get("Message") or {}).get("ShipStaticData")
if static and isinstance(static.get("Type"), int):
label = _bucket(static["Type"])
types[region_name][label] = types[region_name].get(label, 0) + 1
ship_name = (static.get("Name") or metadata.get("ShipName") or "").strip()
if ship_name and label in ("self_declared_military_ops", "self_declared_law_enforcement"):
names[region_name][ship_name] = label
return seen, types, names, received
def fetch(lookback_hours):
del lookback_hours # AIS arrives as a live stream; this is a sample of now, not of a window
api_key = os.environ.get("AISSTREAM_API_KEY", "").strip()
if not api_key:
LOG.warning("naval_traffic: AISSTREAM_API_KEY not set, skipping")
return []
regions = _parse_regions(os.environ.get("NAVAL_REGIONS", DEFAULT_REGIONS))
if not regions:
LOG.warning("naval_traffic: NAVAL_REGIONS parsed to nothing, skipping")
return []
sample_seconds = float(os.environ.get("NAVAL_SAMPLE_SECONDS", "20"))
# Imported here, not at module scope, so a missing/broken optional dependency
# degrades this one source instead of the whole run.
try:
import websocket
except ImportError:
LOG.warning("naval_traffic: websocket-client is not installed, skipping", exc_info=True)
return []
socket = None
try:
socket = websocket.create_connection(STREAM_URL, timeout=sample_seconds)
socket.send(
json.dumps(
{
"APIKey": api_key,
# aisstream orders corners [lat, lon], not GeoJSON's [lon, lat].
"BoundingBoxes": [
[[region["lamin"], region["lomin"]], [region["lamax"], region["lomax"]]]
for region in regions
],
"FilterMessageTypes": ["PositionReport", "ShipStaticData"],
}
)
)
seen, types, names, received = _sample(socket, regions, time.monotonic() + sample_seconds)
except Exception:
LOG.warning("naval_traffic: aisstream sample failed, skipping", exc_info=True)
return []
finally:
if socket is not None:
try:
socket.close()
except Exception:
LOG.debug("naval_traffic: socket close failed", exc_info=True)
# aisstream rejects a bad key by closing the socket without an error message, so
# a sample that saw literally nothing is indistinguishable from a rejected
# subscription. Contributing nothing is honest; contributing "zero vessels in
# every chokepoint" would be a fabricated finding the prompt would have to trust.
if not received:
LOG.warning("naval_traffic: no AIS messages in the sample window (bad key, or no traffic), skipping")
return []
observed_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
results = []
for region in regions:
name = region["name"]
results.append(
{
"source": "aisstream",
"category": "naval_traffic",
"region": name,
"bbox": [region["lamin"], region["lomin"], region["lamax"], region["lomax"]],
"observed_at": observed_at,
"sample_seconds": sample_seconds,
"distinct_vessels": len(seen[name]),
"vessel_types": types[name],
"self_declared_state_vessels": names[name],
"attribution": "AIS data via aisstream.io",
"caveat": (
"Civil AIS only, sampled for a few seconds. Warships routinely sail with "
"AIS off, so this cannot show naval force posture; it shows whether "
"merchant traffic is still using the chokepoint."
),
}
)
LOG.info(
"naval_traffic: %d region(s), %d distinct vessel(s) in a %ss sample",
len(results),
sum(len(value) for value in seen.values()),
sample_seconds,
)
return results