はじめに
AIS(Automatic Identification System)は、船舶が自分の位置・速度・航行情報を送信する国際標準システム。VHF帯の無線通信を使って、船舶同士・陸上局・衛星に向けてデータを送信することで、世界中の船の位置がわかる。
本記事では、WebSocket で AIS データを配信してくれる aisstream.io を利用して、
Pythonコードでリアルタイムの船舶位置を 地図上にプロット してみる。
目次
aisstream.ioとは
aisstreamioは無料のWebSocket APIで、GitHubアカウントでAPIキーを取得できる。
aisstream.io が提供する AIS データは、陸上AIS局で受信された信号をリアルタイム配信している。衛星AIS局のデータはカバーしていないため、沿岸部の船舶データが中心となる。外洋の船舶まではカバーしていない点注意。
戻る
AISデータ取得のサンプルコード
aisstream.ioのAPIを使って取得したデータを整理し、csvファイルに出力するサンプルコード。ポイントをまとめてみた。詳細はAPIのドキュメント参照。
-
APIで取得する情報を指定する
FilterMessageTypesで取得するメッセージを指定する。
BoundingBoxesで範囲を指定。[[南西の緯度, 南西の経度], [北東の緯度, 北東の経度]]例えば下記のように指定する。- 東京湾 :[[35.0, 139.6], [35.7, 140.1]]
- 日本 :[[24.0, 122.0], [46.0, 154.0]]
- ホルムズ海峡:[[25.0, 55.0], [27.5, 57.0]]
- 世界 :[[-90.0, -180.0], [90.0, 180.0]]
subscribe_msg = {
'APIKey': key,
'BoundingBoxes': [range],
'FilterMessageTypes': ['PositionReport','ShipStaticData','StaticDataReport']
}
-
NavigationalStatusで船の状態を取得
https://api.vtexplorer.com/docs/ref-navstat.html
StatusDic = {
0: '機関使用中で航行', 1: '錨泊中', 2: '操船不能', 3: '操船制限', 4: '喫水制限', 5: '係留中', 6: '座礁中', 7: '漁労中', 8: '帆走中',
9: '予約', 10: '予約', 11: '予約', 12: '予約', 13: '予約',
14: '遭難信号', 15: '未定義'
}
-
船籍を取得
MMSIの先頭3桁が国番号になっている。
https://www.itu.int/en/itu-r/terrestrial/fmd/pages/mid.aspx
Coutrydic= {
201: "Albania", 202: "Andorra", 203: "Austria", 204: "Portugal - Azores",
205: "Belgium", 206: "Belarus", 207: "Bulgaria", 208: "Vatican City State",
.....
745: "France - Guiana", 750: "Guyana",
755: "Paraguay", 760: "Peru", 765: "Suriname",
770: "Uruguay", 775: "Venezuela",
}
- サンプルコードの関数
| 関数名 | 処理内容 |
|---|---|
| get_country_mmsi(mmsi) | 船籍取得 |
| output_PositionReport(msg) | PositionReportから船の位置や状態を取得 |
| output_ShipStaticData(msg) | ShipStaticDataから船の情報を取得 |
| output_StaticDataReport(msg) | StaticDataReport船のタイプを取得 |
| get_ais(url,key,range,q) | AISを取得する処理。Task1 |
| recoreds_ais(q) | データrecoreds[]に詰めていく。同じMMSIは上書きする。Task2 |
| save_to_csv(sec) | sec秒ごとにcsvファイルに保存する。Task3 |
| main() | メイン関数 |
- サンプルコード
ais_sample.py
import asyncio
import websockets
import pandas as pd
import json
import os
from datetime import datetime, timezone
url = 'wss://stream.aisstream.io/v0/stream'
key = '取得したAPIキーを設定する'
range = [[35.0, 139.6], [35.7, 140.1]] # 東京湾
#range = [[-90.0, -180.0], [90.0, 180.0]] # 世界
def get_country_mmsi(mmsi):
Coutrydic= {
201: "Albania", 202: "Andorra", 203: "Austria", 204: "Portugal - Azores",
205: "Belgium", 206: "Belarus", 207: "Bulgaria", 208: "Vatican City State",
209: "Cyprus", 210: "Cyprus", 211: "Germany", 212: "Cyprus",
213: "Georgia", 214: "Moldova", 215: "Malta", 216: "Armenia",
218: "Germany", 219: "Denmark", 220: "Denmark", 224: "Spain",
225: "Spain", 226: "France", 227: "France", 228: "France",
229: "Malta", 230: "Finland", 231: "Denmark - Faroe Islands",
232: "United Kingdom - Great Britain and Northern Ireland",
233: "United Kingdom - Great Britain and Northern Ireland",
234: "United Kingdom - Great Britain and Northern Ireland",
235: "United Kingdom - Great Britain and Northern Ireland",
236: "United Kingdom - Gibraltar", 237: "Greece", 238: "Croatia",
239: "Greece", 240: "Greece", 241: "Greece", 242: "Morocco",
243: "Hungary", 244: "Netherlands",
245: "Netherlands", 246: "Netherlands", 247: "Italy", 248: "Malta",
249: "Malta", 250: "Ireland", 251: "Iceland", 252: "Liechtenstein",
253: "Luxembourg", 254: "Monaco", 255: "Portugal - Madeira",
256: "Malta", 257: "Norway", 258: "Norway", 259: "Norway",
261: "Poland", 262: "Montenegro", 263: "Portugal", 264: "Romania",
265: "Sweden",
266: "Sweden", 267: "Slovak Republic", 268: "San Marino",
269: "Switzerland", 270: "Czech Republic", 271: "Türkiye",
272: "Ukraine", 273: "Russian Federation", 274: "North Macedonia",
275: "Latvia", 276: "Estonia", 277: "Lithuania", 278: "Slovenia",
279: "Serbia", 301: "United Kingdom - Anguilla",
303: "United States - Alaska", 304: "Antigua and Barbuda",
305: "Antigua and Barbuda", 306: "Netherlands", 307: "Netherlands",
308: "Bahamas", 309: "Bahamas", 310: "United Kingdom - Bermuda",
311: "Bahamas", 312: "Belize", 314: "Barbados", 316: "Canada",
319: "United Kingdom - Cayman Islands", 321: "Costa Rica",
323: "Cuba", 325: "Dominica", 327: "Dominican Republic",
329: "France - Guadeloupe", 330: "Grenada",
331: "Denmark - Greenland", 332: "Guatemala", 334: "Honduras",
336: "Haiti", 338: "United States of America", 339: "Jamaica",
341: "Saint Kitts and Nevis", 343: "Saint Lucia", 345: "Mexico",
347: "France - Martinique", 348: "United Kingdom - Montserrat",
350: "Nicaragua", 351: "Panama", 352: "Panama", 353: "Panama",
354: "Panama", 355: "Panama", 356: "Panama", 357: "Panama",
358: "United States - Puerto Rico", 359: "El Salvador",
361: "France - Saint Pierre and Miquelon",
362: "Trinidad and Tobago", 364: "United Kingdom - Turks and Caicos Islands",
366: "United States of America", 367: "United States of America",
368: "United States of America",
369: "United States of America", 370: "Panama", 371: "Panama",
372: "Panama", 373: "Panama", 374: "Panama",
375: "Saint Vincent and the Grenadines",
376: "Saint Vincent and the Grenadines",
377: "Saint Vincent and the Grenadines",
378: "United Kingdom - British Virgin Islands",
379: "United States - United States Virgin Islands",
401: "Afghanistan", 403: "Saudi Arabia", 405: "Bangladesh",
408: "Bahrain", 410: "Bhutan", 412: "China", 413: "China",
414: "China", 416: "China",
417: "Sri Lanka", 419: "India", 422: "Iran", 423: "Azerbaijan",
425: "Iraq", 428: "Israel", 431: "Japan", 432: "Japan",
434: "Turkmenistan", 436: "Kazakhstan", 437: "Uzbekistan",
438: "Jordan", 440: "Korea", 441: "Korea",
443: "State of Palestine", 445: "North Korea", 447: "Kuwait",
450: "Lebanon", 451: "Kyrgyz Republic", 453: "China",
455: "Maldives", 457: "Mongolia", 459: "Nepal", 461: "Oman",
463: "Pakistan", 466: "Qatar", 468: "Syria",
470: "United Arab Emirates", 471: "United Arab Emirates",
472: "Tajikistan", 473: "Yemen", 475: "Yemen",
477: "China", 478: "Bosnia and Herzegovina",
501: "France - Adelie Land", 503: "Australia",
506: "Myanmar", 508: "Brunei Darussalam", 510: "Micronesia",
511: "Palau",
512: "New Zealand", 514: "Cambodia", 515: "Cambodia",
516: "Australia - Christmas Island",
518: "New Zealand - Cook Islands", 520: "Fiji",
523: "Australia - Cocos", 525: "Indonesia", 529: "Kiribati",
531: "Laos", 533: "Malaysia",
536: "United States - Northern Mariana Islands",
538: "Marshall Islands", 540: "France - New Caledonia",
542: "New Zealand - Niue", 544: "Nauru",
546: "France - French Polynesia", 548: "Philippines",
550: "Timor-Leste", 553: "Papua New Guinea",
555: "United Kingdom - Pitcairn Island",
557: "Solomon Islands", 559: "United States - American Samoa",
561: "Samoa", 563: "Singapore", 564: "Singapore",
565: "Singapore", 566: "Singapore", 567: "Thailand",
570: "Tonga", 572: "Tuvalu", 574: "Viet Nam",
576: "Vanuatu", 577: "Vanuatu",
578: "France - Wallis and Futuna Islands",
601: "South Africa", 603: "Angola", 605: "Algeria",
607: "France - Saint Paul and Amsterdam Islands",
608: "United Kingdom - Ascension Island",
609: "Burundi", 610: "Benin", 611: "Botswana",
612: "Central African Republic", 613: "Cameroon",
615: "Congo", 616: "Comoros", 617: "Cabo Verde",
618: "France - Crozet Archipelago", 619: "Côte d'Ivoire",
620: "Comoros", 621: "Djibouti", 622: "Egypt",
624: "Ethiopia", 625: "Eritrea", 626: "Gabon",
627: "Ghana", 629: "Gambia", 630: "Guinea-Bissau",
631: "Equatorial Guinea", 632: "Guinea", 633: "Burkina Faso",
634: "Kenya", 635: "France - Kerguelen Islands",
636: "Liberia", 637: "Liberia", 638: "South Sudan",
642: "Libya", 644: "Lesotho", 645: "Mauritius",
647: "Madagascar", 649: "Mali", 650: "Mozambique",
654: "Mauritania", 655: "Malawi", 656: "Niger",
657: "Nigeria", 659: "Namibia", 660: "France - Reunion",
661: "Rwanda", 662: "Sudan", 663: "Senegal",
664: "Seychelles", 665: "United Kingdom - Saint Helena",
666: "Somalia", 667: "Sierra Leone",
668: "Sao Tome and Principe", 669: "Eswatini",
670: "Chad", 671: "Togo", 672: "Tunisia",
674: "Tanzania", 675: "Uganda",
676: "Democratic Republic of the Congo",
677: "Tanzania", 678: "Zambia", 679: "Zimbabwe",
701: "Argentina", 710: "Brazil", 720: "Bolivia",
725: "Chile", 730: "Colombia", 735: "Ecuador",
740: "United Kingdom - Falkland Islands",
745: "France - Guiana", 750: "Guyana",
755: "Paraguay", 760: "Peru", 765: "Suriname",
770: "Uruguay", 775: "Venezuela",
}
mid = mmsi // 1_000_000
return Coutrydic.get(mid, "不明")
def output_PositionReport(msg):
StatusDic = {
0: '機関使用中で航行', 1: '錨泊中', 2: '操船不能', 3: '操船制限', 4: '喫水制限', 5: '係留中', 6: '座礁中', 7: '漁労中', 8: '帆走中',
9: '予約', 10: '予約', 11: '予約', 12: '予約', 13: '予約',
14: '遭難信号', 15: '未定義'
}
msgdata = {
'type': 'posdata',
'id':msg['MetaData']['MMSI'],
'data':{
'MMSI': msg['MetaData']['MMSI'],
'Country': get_country_mmsi(msg['MetaData']['MMSI']),
'ShipName': msg['MetaData']['ShipName'],
'latitude': msg['MetaData']['latitude'],
'longitude': msg['MetaData']['longitude'],
'time_utc': msg['MetaData']['time_utc'],
'Cog': msg['Message']['PositionReport']['Cog'],
'Sog': msg['Message']['PositionReport']['Sog'],
'TrueHeading': msg['Message']['PositionReport']['TrueHeading'],
'RateOfTurn': msg['Message']['PositionReport']['RateOfTurn'],
'NavigationalStatus_type': msg['Message']['PositionReport']['NavigationalStatus'],
'NavigationalStatus': StatusDic[msg['Message']['PositionReport']['NavigationalStatus']],
}
}
return msgdata
def output_ShipStaticData(msg):
msgdata = {
'type': 'shipdata',
'id':msg['MetaData']['MMSI'],
'data':{
'MMSI': msg['MetaData']['MMSI'],
'Destination': msg['Message']['ShipStaticData']['Destination'],
'CallSign': msg['Message']['ShipStaticData']['CallSign'],
'A(船首)': msg['Message']['ShipStaticData']['Dimension']['A'],
'B(船尾)': msg['Message']['ShipStaticData']['Dimension']['B'],
'C(左舷)': msg['Message']['ShipStaticData']['Dimension']['C'],
'D(右舷)': msg['Message']['ShipStaticData']['Dimension']['D'],
}
}
return msgdata
def output_StaticDataReport(msg):
msgdata = {
'type': 'staticdata',
'id':msg['MetaData']['MMSI'],
'data':{
'MMSI': msg['MetaData']['MMSI'],
'ShipType': msg['Message']['StaticDataReport']['ReportB']['ShipType'],
}
}
return msgdata
recoreds = []
async def recoreds_ais(q):
global recoreds
while True:
data = await q.get()
mmsi = data["id"]
# 既存データを探す
found = None
for r in recoreds:
if r["id"] == mmsi:
found = r
break
# 見つからなければ新規作成
if found is None:
found = {"id": mmsi, "data": {}}
recoreds.append(found)
# データをマージ(上書きではなく追加)
found["data"].update(data["data"])
async def save_to_csv(sec):
global recoreds
filename = 'ais_output.csv'
while 1:
await asyncio.sleep(sec)
print(len(recoreds))
df = pd.DataFrame([r['data'] for r in recoreds])
df.to_csv(filename, index=False, encoding='utf-8-sig')
async def get_ais(url,key,range,q):
async with websockets.connect(url) as websocket:
subscribe_msg = {
'APIKey': key,
'BoundingBoxes': [range],
'FilterMessageTypes': ['PositionReport','ShipStaticData','StaticDataReport']
}
await websocket.send(json.dumps(subscribe_msg))
async for msg_json in websocket:
try:
msg = json.loads(msg_json)
msg_type = msg.get('MessageType')
if msg_type == 'PositionReport':
await q.put(output_PositionReport(msg))
elif msg_type == 'ShipStaticData':
await q.put(output_ShipStaticData(msg))
elif msg_type == 'StaticDataReport':
await q.put(output_StaticDataReport(msg))
except json.JSONDecodeError:
continue
async def main():
q =asyncio.Queue() # Queue準備
task1 = asyncio.create_task(get_ais(url,key,range,q))
task2 = asyncio.create_task(recoreds_ais(q))
task3 = asyncio.create_task(save_to_csv(10))
await asyncio.gather(task1,task2,task3)
if __name__ == '__main__':
asyncio.run(main())
地図にマッピング
foliumを使ってcsvの経度緯度情報からマッピング。
map.py
import pandas as pd
import folium
# CSV 読み込み
df = pd.read_csv("ais_output.csv")
lat_col = "latitude"
lon_col = "longitude"
name_col = "ShipName"
# 欠損行を除外
df = df.dropna(subset=[lat_col, lon_col])
# 空欄なら "Unknown" にする
df[name_col] = df[name_col].fillna("Unknown")
df["NavigationalStatus"] = df["NavigationalStatus"].fillna("Unknown")
df["Country"] = df["Country"].fillna("Unknown")
# 地図の中心をcsvの最初の船にする
center_lat = df[lat_col].iloc[0]
center_lon = df[lon_col].iloc[0]
m = folium.Map(location=[center_lat, center_lon], zoom_start=8)
# マーカ追加
for _, row in df.iterrows():
popup_text = (
f"{row[name_col]}<br>"
f"MMSI:{row['MMSI']}<br>"
f"Country:{row['Country']}<br>"
f"{row['NavigationalStatus']}"
)
folium.Marker(
location=[row[lat_col], row[lon_col]],
popup=popup_text,
tooltip=row[name_col]
).add_to(m)
# HTMLとして保存
m.save("ais_map.html")
参考記事