249 lines
9.4 KiB
Python
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
|