"""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