Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
A hands-on quick start guide for using AISdb.
If you are starting from scratch, download the example ".db" file from our AISdb Tutorial GitHub repository so you can follow this guide with real data.
To work with the AISdb Python package, please ensure you have Python version 3.10 or higher. If you plan to use SQLite, no additional installation is required, as it is included with Python by default. Those who prefer using a PostgreSQL server must install it separately and can optionally enable the TimescaleDB extension for better performance on large time-series ingests.
The AISdb Python package can be conveniently installed using pip. It's highly recommended that a virtual Python environment be created and the package installed within it.
python -m venv
python -m venv
You can test your installation by running the following commands:
python
>>> import aisdb
>>> aisdb.__version__ # '1.8.0a0' when built from source, or the latest PyPI releaseIf you are running Jupyter, ensure it is installed in the same environment as AISdb:
source ./.venv/bin/activate
pip install jupyter
jupyter notebookThe Python code in the rest of this document can be run in the Python environment you created.
To use nightly builds (not mandatory), you can install AISdb from source:
Alternatively, you can use nightly builds (not mandatory) on Google Colab as follows:
AISdb supports SQLite and PostgreSQL databases. When using PostgreSQL, is an optional extension that AISdb can take advantage of for its automatic partitioning and compression of time-series data. It is not required (PostgreSQL alone works fine), but for large ingests it is worth setting up. decode_msgs() exposes a timescaledb argument (default False) that switches on the TimescaleDB-aware table schema when inserting data. If you want to enable it, follow these steps first.
This uses psycopg, which installs automatically with pip install aisdb, so there's nothing extra to add for interfacing with PostgreSQL databases. PostgreSQL accepts these . Alternatively, a connection string may be used. Information on connection strings and PostgreSQL URI format can be found .
Querying SQLite is as easy as providing the name of a ".db" file with the same entity-relationship model as the databases supported by AISdb, which are detailed in the section. We prepared an example SQLite database example_data.db based on AIS data from a small region near Maine, United States, in January 2022 from , which is available in the AISdb GitHub repository.
If you want to create your database using your data, we have a with examples that show you how to create an SQLite database from open-source data.
Parameters for the database query can be defined using . Iterate over rows returned from the database for each vessel with . Convert the results into a generator yielding dictionaries with NumPy arrays describing position vectors, e.g., lon, lat, and time, using .
The following query will return vessel trajectories from a given 1-hour time window:
A specific region can be queried for AIS data using or one of its sub-classes to define a collection of shapely polygon features. For this example, the domain contains a single bounding box polygon derived from a longitude/latitude coordinate pair and radial distance specified in meters. If multiple features are included in the domain object, the domain boundaries will encompass the convex hull of all features.
Additional query callbacks for filtering by region, timeframe, identifier, etc., can be found in and .
The above generator can be input into a processing function, yielding modified results. For example, when modeling the activity of vessels on a per-voyage or per-transit basis, each voyage is defined as a continuous vector of positions where the time between observed timestamps never exceeds 24 hours.
A common problem with AIS data is noise, where multiple vessels might broadcast using the same identifier (sometimes simultaneously). AISdb denoises this data in three steps.
First, denoising with the encoder. The function checks the approximate distance between each vessel's position. It separates vectors where a vessel couldn't reasonably travel using the most direct path, such as when the implied speed exceeds 50 knots.
Second, distance and speed thresholds. These thresholds limit the maximum distance or time between messages that can be considered continuous.
Third, scoring and segment concatenation. A score is computed for each position delta, with sequential messages nearby at shorter intervals given a higher score. This score is calculated by dividing the Haversine distance by elapsed time. Any deltas with a score not reaching the minimum threshold are considered the start of a new segment. New segments are compared to the end of existing segments with the same vessel identifier, and if the score exceeds the minimum, they are concatenated. If multiple segments meet the minimum score, the new segment is concatenated to the existing segment with the highest score.
Notice that processing functions may be executed in sequence as a chain or pipeline, so after segmenting the individual voyages as shown above, results can be input into the encoder to remove noise and correct for vessels with duplicate identifiers.
Building on the above processing pipeline, the resulting cleaned trajectories can be geofenced and filtered for results contained by at least one domain polygon and interpolated for uniformity.
Additional processing functions can be found in the module.
The resulting processed voyage data can be exported in CSV format instead of being printed:
AISdb supports integrating external data sources such as bathymetric charts and other raster grids.
To determine the approximate ocean depth at each vessel position, the module can be used.
Once the data has been downloaded, the class may be used to append bathymetric data to tracks in the context of a processing pipeline like the processing functions described above.
Also, see for determining the approximate nearest distance to shore from vessel positions.
Similarly, arbitrary coordinate-gridded raster data may be appended to vessel tracks.
AIS data from the database may be overlaid on a map such as the one shown below using the function. This function accepts a generator of track dictionaries such as those output by .
For a complete plug-and-play solution, you may clone our .
This quick start covers the basics of loading, querying, and visualizing AIS data. The section goes deeper into each step, starting with , then , and for handling noisy or duplicate position reports.
A little bit about where we stand.
Welcome to AISdb, a comprehensive gateway for Automatic Identification System (AIS) data use and applications. AISdb is part of the Making Maritime Informatics Accessible to Everyone (AISViz) project, developed and maintained by the MAPS Lab at Dalhousie University and building on earlier work from the MERIDIAN initiative. It is designed to streamline the collection, processing, and analysis of AIS data, both in live-streaming scenarios and through historical records.
At the heart of AISdb is a database built on SQLite, giving users a friendly Python interface with which to interact. This interface simplifies tasks like database creation, data querying, processing, visualization, and exporting data to CSV format for diverse uses. To cater to advanced needs, AISdb also supports PostgreSQL, including an optional TimescaleDB extension, offering superior concurrency handling and data-sharing capabilities for collaborative environments.
AISdb includes vessel position cleaning and trajectory modeling features. This ensures that the data used for analyses is accurate and reliable, providing a solid foundation for further studies and applications.
One of AISdb's unique features is its ability to enrich AIS datasets with environmental context. Users can seamlessly integrate oceanographic and bathymetric data in raster formats to bring depth to their analyses, quite literally, as the tool allows for incorporating seafloor depth data underneath vessel positions. Such versatility ensures that AISdb users can merge various environmental data points with AIS information, resulting in richer, multi-faceted maritime studies.
AISdb offers network graph analysis, MMSI deduplication, interpolation, and other processing utilities. These features enable advanced data processing and analysis, supporting complex maritime studies and applications.
AISdb includes a Python interface with a Rust core, built through PyO3 and Maturin, that paves the way for incorporating machine learning and deep learning techniques into vessel behavior modeling in an optimized way. This aspect of AISdb enhances the reproducibility and scalability of research, be it for academic exploration or practical industry applications.
AISdb is more than just a storage and processing tool. It is a comprehensive platform designed to support research. Through a formal partnership with our research initiative (contact us for more information), academics, industry experts, and researchers can access extensive Canadian AIS data up to 100 km from the Canadian coastline. This dataset spans from January 2012 to the present and is updated monthly. AISdb offers raw and parsed data formats, eliminating preprocessing needs and streamlining AIS-related research.
The MAPS Lab team is based in the lab in collaboration with the research group at Dalhousie University. Our team is funded by the , and our mission revolves around democratizing AIS data use, making it accessible and understandable across multiple sectors, from government and academia to NGOs and the broader public. In addition, AISViz aims to introduce machine learning applications into AISdb's AIS data handling. This effort seeks to streamline user interactions with AIS data, enhancing the user experience by simplifying data access.
Our commitment goes beyond just providing tools. Through AISViz, we're opening doors to innovative research and policy development, targeting environmental conservation, maritime traffic management, and much more. Whether you're a professional in the field, an educator, or a maritime enthusiast, AISViz and its components, including AISdb, offer the knowledge and technology to deepen your understanding and significantly impact marine vessel tracking and the well-being of our oceans.
Ruixin Song is a research assistant in the Computer Science Department at Dalhousie University. She has an M.Sc. in Computer Science and a B.Eng. in Spatial Information and Digital Technology. Her recent work focuses on marine traffic data analysis and physics-inspired models, particularly in relation to biological invasions in the ocean. Her research interests include mobility data mining, graph neural networks, and network flow and optimization problems.
Contact:
Gabriel Spadon is an Assistant Professor at the Faculty of Computer Science at Dalhousie University, Halifax - NS, Canada. He holds a Ph.D. and an MSc in Computer Science from the University of Sao Paulo, Sao Carlos - SP, Brazil. His research focuses on spatio-temporal analytics, time-series forecasting, and complex network mining, with a deep involvement in data science and engineering, as well as geoinformatics.
Vaishnav Vaidheeswaran is a Master's student in Computer Science at Dalhousie University. He holds a B.Tech in Computer Science and Engineering and has three years of experience as a software engineer in India, working at cutting-edge startups. His ongoing work focuses on incorporating spatial knowledge into trajectory forecasting models to reduce the aleatoric uncertainty arising from stochastic interactions between the vessel and the environment. His research interests include large language models, graph neural networks, and reinforcement learning.
Contact:
Parth Doshi is a Bachelor's student in Computer Science at Dalhousie University. His ongoing work focuses on developing a Generative Adversarial Network that generates vessel trajectories through agents, as well as on vessel spoofing detection. His research interests include time-series-based forecasting and inverse reinforcement learning.
Jinkun Chen is a Ph.D. student in Computer Science at Dalhousie University, specializing in Explainable AI, Natural Language Processing (NLP), and Visualization. He earned a bachelor's degree in Computer Science with First-Class Honours from Dalhousie University. Jinkun is actively involved in research, working on advancing fairness, responsibility, trustworthiness, and explainability within Large Language Models (LLMs) and AI.
Jay Kumar has a Ph.D. in Computer Science and Technology and was a postdoctoral fellow at the Department of Industrial Engineering at Dalhousie University. He has researched AI models for time-series data for over five years, focusing on Recurrent Neural models, probabilistic modeling, and feature engineering data analytics applied to ocean traffic. His research interests include Spatio-temporal Data Mining, Stochastic Modeling, Machine Learning, and Deep Learning.
Matthew Smith has a BSc degree in Applied Computer Science from Dalhousie University and specializes in managing and analyzing vessel tracking data. He is currently a Software Engineer at Radformation in Toronto, ON. Matt served as the AIS data manager on the MERIDIAN project, where he supported research groups across Canada in accessing and utilizing AIS data. The data was used to answer a range of scientific queries, including the impact of shipping on underwater noise pollution and the danger posed to endangered marine mammals by vessel collisions.
We are passionate about fostering a collaborative and engaged community. We welcome your questions, insights, and feedback as vital components of our continuous improvement and innovation. Should you have any inquiries about AISdb, desire further information on our research, or wish to explore potential collaborations, please don't hesitate to contact us. Staying connected with users and researchers plays a crucial role in shaping the tool's development and ensuring it meets the diverse needs of our growing user base. You can easily contact our team via email or our . In addition to addressing individual queries, we organize webinars and workshops and present at conferences to share knowledge, gather feedback, and widen our outreach. Together, let's advance the understanding and utilization of marine data for a brighter, more informed future in ocean research and preservation.
How to deploy your own Automatic Identification System (AIS) receiver.
In addition to utilizing AIS data provided by Spire for the Canadian coasts, you can install AIS receiver hardware to capture AIS data directly. The received data can be processed and stored in databases, which can then be used with AISdb. This approach offers additional data sources and allows you to collect and process your own data (as illustrated in the pipeline below). Doing so lets you customize your data collection efforts to meet specific needs and seamlessly integrate the data with AISdb for enhanced analysis and application. At the same time, you can share the data you collect with others.
Raspberry Pi or other computers with internet connectivity
162MHz receiver, such as the
An antenna in the VHF frequency band (30MHz - 300MHz), e.g., Shakespeare QC-4 VHF Antenna
Optionally, you may want
Antenna mount
A filtered preamp, such as , to improve signal range and quality
Another option is free AIS receivers from . This option may require you to share the data with the organization to help expand its AIS-receiving network.
When setting up your antenna, place it as high as possible and as far away from obstructions and other equipment as is practical.
Connect the antenna to the receiver. If using a preamp filter, connect it between the antenna and the receiver.
Connect the receiver to your Linux device via a USB cable. If using a preamp filter, power it with a USB cable.
Below is the antenna hardware setup MERIDIAN uses.
AISdb's receiver is a Rust component exposed to Python as aisdb.start_receiver(). It listens for AIS traffic on a UDP or TCP socket, parses the NMEA sentences, and writes the decoded positions and static reports straight into an SQLite or PostgreSQL database. It does not read a serial port directly, so a USB receiver such as the dAISy needs a small bridge program in front of it to forward the serial bytes onto a local network port. mproxy-client, the same crate the AISdb receiver links against internally, does that job.
On the Raspberry Pi (or whatever Linux box the receiver is plugged into), install Python 3.10 or newer and pull AISdb from PyPI.
pip install aisdb ships prebuilt wheels for the common platforms, so a Rust toolchain is not required just to run the receiver. You only need Rust if you are building AISdb from source.
Attach the dAISy (or equivalent) to the Pi over USB, log in, and update the system with sudo apt-get update.
Needed to build mproxy-client. Run curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh, then log out and back in so cargo is on the path.
With the bridge forwarding raw NMEA onto 127.0.0.1:9921, point aisdb.start_receiver() at that port. Save this as ./run_receiver.py.
sqlite_dbpath can be swapped for postgres_connection_string if you would rather write straight into PostgreSQL. connect_addr defaults to aisviz.cs.dal.ca:9920, MERIDIAN's public aggregator. Leaving it at the default also merges MERIDIAN's live feed into your own stream, which is convenient if you want a fuller picture of traffic than your antenna alone can see, but set it to None (as above) if you only want the messages your own receiver decodes.
Wrap the script in its own systemd service the same way as the bridge, pointing ExecStart at your Python interpreter and run_receiver.py, and enable it so both services come up together on boot.
For some Raspberry Pi hardware (the Raspberry Pi 4 Model B is a common example), the device file Linux assigns to the receiver is not always /dev/ttyACM0 as used in the systemd unit above.
Check which device file is actually in use.
On some boards serial0 is linked to ttyS0 rather than a ttyACM* device.
Simply changing /dev/ttyACM0 to /dev/ttyS0 in the service file may produce garbled AIS sentences, because the default baud rate on that port does not match what the receiver expects. Set the baud rate explicitly.
How AISdb organizes decoded AIS messages across SQLite and PostgreSQL/TimescaleDB tables, and how to query them directly with SQL.
AISdb stores decoded AIS messages in a relational database rather than flat files, which is what makes range queries, joins across message types, and multi-year aggregations practical. Two backends are supported. SQLite is the default for local work, a single-file database that needs no server and is easy to copy or drop into version control for small projects. PostgreSQL is the option for shared or production deployments, and it can optionally run on top of TimescaleDB for large, high-ingest-rate collections. Both backends expose the same table layout and the same DBQuery/TrackGen query interface, so switching between them later mostly means swapping the connection object.
Every AISdb database connection is opened through one of two classes in aisdb.database.dbconn, both of which subclass a shared DBConn base and both of which support the context manager protocol (with ... as dbconn:).
SQLiteDBConn wraps a sqlite3.Connection and only needs a filesystem path. If the file does not exist yet, it will be created the first time you decode messages into it.
PostgresDBConn wraps a psycopg connection and accepts either a libpq connection string or individual keyword arguments (host, port, user, dbname, password, and any other keyword recognized by ).
Pick SQLite for quick exploration on your own machine, and PostgreSQL when several people or processes need to read and write the same database, when the collection grows past what a single SQLite file handles comfortably, or when you plan to enable TimescaleDB (see below). Regardless of the backend, accepts either connection type as its dbconn argument, and the same DBQuery/TrackGen code works unchanged against both.
When loading data into the database, messages are sorted into SQL tables determined by the message type and month. The table names follow this format, where {YYYYMM} is the four-digit year and two-digit month.
Some additional tables containing computed data may be created depending on the indexes used. Examples include an aggregate of vessel static data by month and a virtual table used as a covering index.
Additional tables are also included for storing data not directly derived from AIS message reports.
For quick reference to data types and detailed explanations of these table entries, please see the .
In addition to querying the database using the module, there is an option to customize the query with your own SQL code. The examples below connect directly with sqlite3, which works against an SQLite database file. Against PostgreSQL, swap sqlite3.connect(dbpath) for psycopg.connect(...) (or reuse the connection already opened through aisdb.PostgresDBConn) and the same SQL runs unchanged, since both backends share identical table and column names.
Example of listing all the tables in your database:
As messages are separated into tables by message type and month, queries spanning multiple message types or months should use UNIONs and JOINs to combine results as appropriate.
Example of querying tables with JOIN:
Each row in results is a plain tuple, one element per selected column, in the order listed in the SELECT. A row from the query above looks like this.
The exact rows you see depend on what is in your database. Rows come back in whatever order SQLite returns them unless you add an ORDER BY clause, and vessel_name/ship_type will be None for any MMSI with no matching row in the static table (since the query uses a LEFT JOIN).
More information about SQL queries can be found in .
The ais_{YYYYMM}_dynamic tables are created with a composite primary key over (mmsi, time, longitude, latitude, sog, cog, source) on SQLite, which acts as a covering index for the queries DBQuery generates. Filtering on a narrow range of MMSIs, timestamps, longitudes, and latitudes lets SQLite satisfy most of the query directly from that index, so query performance improves the more you narrow those bounds. Broad, unbounded queries will scan a much larger share of the table and will not benefit as much. If you need custom indexes for a specific manual query, define them directly on the monthly ais_{YYYYMM}_dynamic and ais_{YYYYMM}_static tables rather than trying to add a separate index structure alongside them.
Timestamps are stored as epoch seconds in the database (seconds since 1970-01-01 UTC), matching the time column described in the . To facilitate querying the database manually, use the dt_2_epoch() function to convert datetime values to epoch seconds and the epoch_2_dt() function to convert epoch seconds back to datetime values. Here is how you can use dt_2_epoch() with the example above:
For more examples, please see the SQL code in that is used to create database tables and associated queries.
is a PostgreSQL extension for time-series data, and AISdb can use it in place of the plain PostgreSQL table layout described above. It is entirely optional. If your PostgreSQL server has the extension installed, opt in when decoding messages by passing timescaledb=True to :
With timescaledb=True, AISdb creates the monthly ais_{YYYYMM}_dynamic and ais_{YYYYMM}_static tables as partitioned on time (and space-partitioned on mmsi), rather than as plain PostgreSQL tables. This is what lets TimescaleDB spread the ingest and query workload for a large, continuously growing AIS collection across many chunks instead of one ever-larger table. The column layout is identical to the plain PostgreSQL schema, so DBQuery, custom SQL, and everything else on this page works the same way regardless of whether TimescaleDB is enabled underneath.
PostgresDBConn also exposes a few maintenance methods that are aware of the timescaledb flag, useful when you are managing indexes or cleaning up a bulk ingest by hand.
Dropping the indexes before a large bulk insert and rebuilding them afterward is significantly faster than maintaining the indexes row by row during the insert. Pass timescaledb=False (the default) for these same methods against a plain PostgreSQL database, which manages indexes named idx_{YYYYMM}_dynamic_mmsi, idx_{YYYYMM}_dynamic_time, idx_{YYYYMM}_dynamic_longitude, and idx_{YYYYMM}_dynamic_latitude instead of the two combined TimescaleDB indexes.
The tutorial walks through decoding raw AIS messages into these tables from scratch, and the tutorial covers DBQuery and TrackGen in more depth than the quick reference above. Reach for the custom SQL approach on this page when you need a query shape those two interfaces do not support directly.
Load AIS data into a SQLite or PostgreSQL database with AISdb, from installation through a full-year Spire ingestion example.
This tutorial will guide you in using the AISdb package to load AIS data into a database and perform queries. We will begin with AISdb installation and environment setup, then proceed to examples of querying the loaded data and creating simple visualizations.
Preparing a Python virtual environment for AISdb is a good practice. It allows you to manage dependencies and prevent conflicts with other projects, ensuring a clean and isolated setup for your work with AISdb. Run these commands in your terminal based on the operating system you are using:
Now you can check your installation by running:
If you're using AISdb in a Notebook, please include the following commands in your notebook cells:
Then, import the required packages:
This section will show you how to efficiently load AIS data into a database.
AISdb includes two database connection approaches:
Denoise AIS tracks with AISdb, removing duplicate-identifier jumps, anchored vessels, and land-locked positions before analysis.
A common issue with AIS data is noise, where multiple vessels transmit under the same identifier, receivers pick up corrupted or drifting positions, or a single vessel's track jumps in ways no real ship could follow. AISdb ships a dedicated denoising module, , to detect and correct these problems before a track is analyzed or visualized.
The module exposes three functions and one class, each targeting a different kind of noise.
encode_greatcircledistance() splits a track wherever consecutive pings imply an impossible speed or distance, then reassembles the pieces that most plausibly belong together.
encode_score()
Contact: spadon@dal.ca
Ron Pelot has a Ph.D. in Management Sciences and is a Professor of Industrial Engineering at Dalhousie University. For the last 30 years, he and his team have been working on developing new software tools and analysis methods for maritime traffic safety, coastal zone security, and marine spills. Their research methods include spatial risk analysis, vessel traffic modeling, data processing, pattern analysis, location models for response resource allocation, safety analyses, and cumulative shipping impact studies.
Contact: ronald.pelot@dal.ca
Contact: parth.doshi@dal.ca
Casey Hilliard has a BSc degree in Computer Science from Dalhousie University and was a Senior Data Manager at the Institute for Big Data Analytics. He is currently a Chief Architect at GSTS (Global Spatial Technology Solutions) in Dartmouth, NS. Casey was a long-time research support staff member at the Institute and an expert in managing and using AIS vessel-tracking data. During his time, he assisted in advancing the Institute's research projects by managing and organizing large datasets, ensuring data integrity, and facilitating data usage in research.
Stan Matwin was the director of the Institute for Big Data Analytics, Dalhousie University, Halifax, Nova Scotia; he is a professor and Canada Research Chair (Tier 1) in Interpretability for Machine Learning. He is also a distinguished professor (Emeritus) at the University of Ottawa and a full professor with the Institute of Computer Science, Polish Academy of Sciences. His main research interests include big data, text mining, machine learning, and data privacy. He is a member of the Editorial Boards of IEEE Transactions on Knowledge and Data Engineering and the Journal of Intelligent Information Systems. He received the Lifetime Achievement Award of the Canadian AI Association (CAIAC).


source .venv/bin/activate # On Windows use `.venv\Scripts\activate`
# Cloning the Repository and installing the package
git clone https://github.com/MAPS-Lab/AISdb.git && cd AISdb
# Windows users can instead download the installer:
# - https://forge.rust-lang.org/infra/other-installation-methods.html#rustup
# - https://static.rust-lang.org/rustup/dist/i686-pc-windows-gnu/rustup-init.exe
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs > install-rust.sh
# Installing Rust and Maturin
/bin/bash install-rust.sh -q -y
pip install --upgrade maturin[patchelf]
# Building AISdb package with Maturin
maturin develop --release --extras=test,docsimport os
# Clone the AISdb repository from GitHub
!git clone https://github.com/MAPS-Lab/AISdb.git
# Install Rust using the official Rustup script
!curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
# Install Maturin to build the packages
!pip install --upgrade maturin[patchelf]
# Set up environment variables
os.environ["PATH"] += os.pathsep + "/root/.cargo/bin"
# Install wasm-pack for building WebAssembly packages
!curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
# Install wasm-pack as a Cargo dependency
!cargo install wasm-pack
# Setting environment variable for the virtual environment
os.environ["VIRTUAL_ENV"] = "/usr/local"
# Change directory to AISdb for building the package
%cd AISdb
# Build and install the AISdb package using Maturin
!maturin develop --release --extras=test,docs$ sudo apt install -y timescaledb-postgresql-XX # XX is the PG-SQL version> CREATE EXTENSION IF NOT EXISTS timescaledb;> SELECT * FROM timescaledb_information.version;$ sudo systemctl restart postgresqlfrom aisdb.database.dbconn import PostgresDBConn
# [OPTION 1]
dbconn = PostgresDBConn(
hostaddr='127.0.0.1', # Replace this with the Postgres address (supports IPv6)
port=5432, # Replace this with the Postgres running port (if not the default)
user='USERNAME', # Replace this with the Postgres username
password='PASSWORD', # Replace this with your password
dbname='DATABASE', # Replace this with your database name
)
# [OPTION 2]
dbconn = PostgresDBConn('postgresql://USERNAME:PASSWORD@HOST:PORT/DATABASE')from aisdb.database.dbconn import SQLiteDBConn
dbpath='example_data.db'
dbconn = SQLiteDBConn(dbpath=dbpath)import aisdb
import pandas as pd
from datetime import datetime
from collections import defaultdict
dbpath = 'example_data.db'
start_time = datetime.strptime("2022-01-01 00:00:00", '%Y-%m-%d %H:%M:%S')
end_time = datetime.strptime("2022-01-01 0:59:59", '%Y-%m-%d %H:%M:%S')
def data2frame(tracks):
# Dictionary where for key/value
ais_data = defaultdict(lambda: pd.DataFrame(
columns = ['time', 'lat', 'lon', 'cog', 'rocog', 'sog', 'delta_sog']))
for track in tracks:
mmsi = track['mmsi']
df = pd.DataFrame({
'time': pd.to_datetime(track['time'], unit='s'),
'lat': track['lat'], 'lon': track['lon'],
'cog': track['cog'], 'sog': track['sog']
})
# Sort by time in descending order
df = df.sort_values(by='time', ascending=False).reset_index(drop=True)
# Compute the time difference in seconds
df['time_diff'] = df['time'].diff().dt.total_seconds()
# Compute RoCOG (Rate of Change of Course Over Ground)
delta_cog = (df['cog'].diff() + 180) % 360 - 180
df['rocog'] = delta_cog / df['time_diff']
# Compute Delta SOG (Rate of Change of Speed Over Ground)
df['delta_sog'] = df['sog'].diff() / df['time_diff']
# Fill NaN values (first row) and infinite values (division by zero cases)
df[['rocog', 'delta_sog']] = df[['rocog', 'delta_sog']].replace([float('inf'), float('-inf')], 0).fillna(0)
# Drop unnecessary column
df.drop(columns = ['time_diff'], inplace=True)
# Store in the dictionary
ais_data[mmsi] = df
return ais_data
with aisdb.SQLiteDBConn(dbpath=dbpath) as dbconn:
qry = aisdb.DBQuery(
dbconn=dbconn, start=start_time, end=end_time,
callback=aisdb.database.sqlfcn_callbacks.in_timerange_validmmsi,
)
rowgen = qry.gen_qry()
tracks = aisdb.track_gen.TrackGen(rowgen, decimate=False)
ais_data = data2frame(tracks) # re-use previous function
# Display DataFrames
for key in ais_data.keys():
print(ais_data[key])# a circle with a 100km radius around the location point
domain = aisdb.DomainFromPoints(points=[(-69.34, 41.55)], radial_distances=[100000])
with aisdb.SQLiteDBConn(dbpath=dbpath) as dbconn:
qry = aisdb.DBQuery(
dbconn=dbconn, start=start_time, end=end_time,
xmin=domain.boundary['xmin'], xmax=domain.boundary['xmax'],
ymin=domain.boundary['ymin'], ymax=domain.boundary['ymax'],
callback=aisdb.database.sqlfcn_callbacks.in_validmmsi_bbox,
)
rowgen = qry.gen_qry()
tracks = aisdb.track_gen.TrackGen(rowgen, decimate=False)
ais_data = data2frame(tracks) # re-use previous function
# Display DataFrames
for key in ais_data.keys():
print(ais_data[key])from datetime import timedelta
# Define a maximum time interval
maxdelta = timedelta(hours=24)
with aisdb.SQLiteDBConn(dbpath=dbpath) as dbconn:
qry = aisdb.DBQuery(
dbconn=dbconn, start=start_time, end=end_time,
xmin=domain.boundary['xmin'], xmax=domain.boundary['xmax'],
ymin=domain.boundary['ymin'], ymax=domain.boundary['ymax'],
callback=aisdb.database.sqlfcn_callbacks.in_validmmsi_bbox,
)
rowgen = qry.gen_qry()
tracks = aisdb.track_gen.TrackGen(rowgen, decimate=False)
# Split the generated tracks into segments
track_segments = aisdb.split_timedelta(tracks, maxdelta)
ais_data = data2frame(track_segments) # re-use previous function
# Display DataFrames
for key in ais_data.keys():
print(ais_data[key])distance_threshold = 20000 # the maximum allowed distance (meters) between consecutive AIS messages
speed_threshold = 50 # the maximum allowed vessel speed in consecutive AIS messages
minscore = 1e-6 # the minimum score threshold for track segment validation
with aisdb.SQLiteDBConn(dbpath=dbpath) as dbconn:
qry = aisdb.DBQuery(
dbconn=dbconn, start=start_time, end=end_time,
callback=aisdb.database.sqlfcn_callbacks.in_timerange_validmmsi,
)
rowgen = qry.gen_qry()
tracks = aisdb.track_gen.TrackGen(rowgen, decimate=False)
# Encode the track segments to clean and validate the track data
tracks_encoded = aisdb.encode_greatcircledistance(tracks,
distance_threshold=distance_threshold,
speed_threshold=speed_threshold,
minscore=minscore)
ais_data = data2frame(tracks_encoded) # re-use previous function
# Display DataFrames
for key in ais_data.keys():
print(ais_data[key])# Define a domain with a central point and corresponding radial distances
domain = aisdb.DomainFromPoints(points=[(-69.34, 41.55),], radial_distances=[100000,])
# Filter the encoded tracks to those inside the domain polygons
tracks_filtered = aisdb.track_gen.fence_tracks(tracks_encoded, domain)
# Interpolate the filtered tracks with a specified time interval
tracks_interp = aisdb.interp_time(tracks_filtered, step=timedelta(minutes=15))aisdb.write_csv(tracks_interp, 'ais_processed.csv')import os
import aisdb
# Set the data storage directory (Gebco expects it to already exist)
data_dir = './testdata/'
os.makedirs(data_dir, exist_ok=True)
# Gebco() downloads the GEBCO bathymetry grid on first use if it is
# not already present in data_dir
bathy = aisdb.webdata.bathymetry.Gebco(data_dir=data_dir)tracks = aisdb.TrackGen(qry.gen_qry(), decimate=False)
tracks_bathymetry = bathy.merge_tracks(tracks) # merge tracks with bathymetry datatracks = aisdb.TrackGen(qry.gen_qry(), decimate=False)
raster_path = './GMT_intermediate_coast_distance_01d.tif'
# Load the raster file
raster = aisdb.webdata.load_raster.RasterFile(raster_path)
# Merge the generated tracks with the raster data
tracks = raster.merge_tracks(tracks, new_track_key="coast_distance")from datetime import datetime, timedelta
import aisdb
from aisdb import DomainFromPoints
dbpath='example_data.db'
def color_tracks(tracks):
''' set the color of each vessel track using a color name or RGB value '''
for track in tracks:
track['color'] = 'blue' or 'rgb(0,0,255)'
yield track
# Set the start and end times for the query
start_time = datetime.strptime("2022-01-01 00:00:00", '%Y-%m-%d %H:%M:%S')
end_time = datetime.strptime("2022-01-31 00:00:00", '%Y-%m-%d %H:%M:%S')
with aisdb.SQLiteDBConn(dbpath=dbpath) as dbconn:
qry = aisdb.DBQuery(
dbconn=dbconn,
start=start_time,
end=end_time,
callback=aisdb.database.sqlfcn_callbacks.in_timerange_validmmsi,
)
rowgen = qry.gen_qry()
# Convert queried rows to vessel trajectories
tracks = aisdb.track_gen.TrackGen(rowgen, decimate=False)
# Visualization
aisdb.web_interface.visualize(
tracks,
visualearth=False,
open_browser=True,
)When connected via USB, the AIS receiver is typically found under /dev/ with a name beginning with ttyACM, for example /dev/ttyACM0. Ensure the device is listed in this directory.
To test the receiver, use the command sudo cat /dev/ttyACM0 to display its output. If all works as intended, you will see streams of bytes appearing on the screen.
mproxy-clientInstall it from crates.io by running cargo install mproxy-client.
Keeps the bridge running and restarts it if the receiver is unplugged and replugged. Create ./mproxy_client.service, replacing User=ais and /home/ais with the username and home directory on your Pi.
[Unit]
Description="AISdb serial-to-UDP bridge"
After=network-online.target
[Service]
Type=simple
User=ais
ExecStart=/home/ais/.cargo/bin/mproxy-client --path /dev/ttyACM0 --server-addr '127.0.0.1:9921'
Restart=always
RestartSec=30
[Install]
WantedBy=default.targetThis forwards whatever the dAISy writes to /dev/ttyACM0 onto UDP port 9921 on the same machine, where the AISdb receiver will be listening. Run mproxy-client --help for the full set of forwarding options if you want to relay to a different host instead.
pip install aisdbimport aisdb
aisdb.start_receiver(
udp_listen_addr="127.0.0.1:9921",
sqlite_dbpath="/home/ais/ais_rx.db",
connect_addr=None,
)ls -l /devstty -F /dev/ttyS0 38400 cs8 -cstopb -parenb

$ sudo cat /dev/ttyACM0
!AIVDM,1,1,,A,B4eIh>@0<voAFw6HKAi7swf1lH@s,0*61
!AIVDM,1,1,,A,14eH4HwvP0sLsMFISQQ@09Vr2<0f,0*7B
!AIVDM,1,1,,A,14eGGT0301sM630IS2hUUavt2HAI,0*4A
!AIVDM,1,1,,B,14eGdb0001sM5sjIS3C5:qpt0L0G,0*0C
!AIVDM,1,1,,A,14eI3ihP14sM1PHIS0a<d?vt2L0R,0*4D
!AIVDM,1,1,,B,14eI@F@000sLtgjISe<W9S4p0D0f,0*24
!AIVDM,1,1,,B,B4eHt=@0:voCah6HRP1;?wg5oP06,0*7B
!AIVDM,1,1,,A,B4eHWD009>oAeDVHIfm87wh7kP06,0*20SQLite database connection; and,
PostgreSQL database connection.
We work with the SQLite database in most usage scenarios. Here is an example of loading data using the sample data included in the AISdb package:
# List the test data files included in the package
print(os.listdir(os.path.join(aisdb.sqlpath, '..', 'tests', 'testdata')))
# You will see files such as:
# ['test_data_20210701.csv', 'test_data_20211101.nm4', 'test_data_20211101.nm4.gz',
# 'test_data_20211101.nm4.zip', 'test_data_201201.nmea', 'test_data_noaa_20230101.csv']
# Set the path for the SQLite database file to be used
dbpath = './test_database.db'
# Use test_data_20210701.csv as the test data
filepaths = [os.path.join(aisdb.sqlpath, '..', 'tests', 'testdata', 'test_data_20210701.csv')]
with aisdb.SQLiteDBConn(dbpath=dbpath) as dbconn:
aisdb.decode_msgs(filepaths=filepaths, dbconn=dbconn, source='TESTING')The code above decodes the AIS messages from the CSV file specified in filepaths and inserts them into the SQLite database connected via dbconn.
The following is a quick example of a query and visualization of the data we just loaded with AISdb:
start_time = datetime.strptime("2021-07-01 00:00:00", '%Y-%m-%d %H:%M:%S')
end_time = datetime.strptime("2021-07-02 00:00:00", '%Y-%m-%d %H:%M:%S')
with aisdb.SQLiteDBConn(dbpath=dbpath) as dbconn:
qry = aisdb.DBQuery(
dbconn=dbconn,
callback=aisdb.database.sqlfcn_callbacks.in_timerange_validmmsi,
start=start_time,
end=end_time,
)
rowgen = qry.gen_qry()
tracks = aisdb.track_gen.TrackGen(rowgen, decimate=False)
if __name__ == '__main__':
aisdb.web_interface.visualize(
tracks,
visualearth=True,
open_browser=True,
)In addition to SQLite, AISdb supports PostgreSQL, which handles concurrent access and data sharing better than SQLite and scales more comfortably to larger, collaborative deployments. psycopg (the PostgreSQL driver AISdb uses under the hood) ships as a core dependency, so pip install aisdb already gives you everything you need, no separate driver install required.
To connect to a PostgreSQL database, AISdb uses the PostgresDBConn class:
After you open a connection to PostgreSQL and point aisdb.decode_msgs at your data files, it runs through file parsing, table creation, data insertion, and index rebuilding, in that order.
Please pay close attention to the arguments of aisdb.decode_msgs, since they control how files are parsed, how fast the load runs, and which table layout gets used. The full signature is decode_msgs(filepaths, dbconn, source, vacuum=False, skip_checksum=True, workers=4, type_preference="all", raw_insertion=True, verbose=True, timescaledb=False). The parameters worth understanding before a large load are:
source (str, required)
A free-form label identifying where the data came from, stored alongside each decoded message (for example "Spire", "NOAA", or "TESTING"). There is no default; you must always pass one. If the string contains "noaa" (case-insensitive), AISdb switches to parsing NOAA's BaseDateTime CSV column instead of the generic Time column.
The following example demonstrates how to process and load Spire data for the entire year 2024 into an aisdb database with the TimescaleDB extension installed:
Example of performing queries and visualizations with PostgreSQL database:
If you want to load your own AIS data instead of the bundled test files, see our guide on data processing and database creation, Using Your AIS Data.
python -m venv .venv # create a python virtual environment
source ./.venv/bin/activate # activate the virtual environment
pip install aisdb # from https://pypi.org/project/aisdb/python -m venv .venv # create a virtual environment
./AISdb/Scripts/activate # activate the virtual environment
pip install aisdb # install the AISdb package using pip$ python
>>> import aisdb
>>> aisdb.__version__ # '1.8.0a0' when built from source, or the latest PyPI release# install nest-asyncio for enabling asyncio.run() in Jupyter Notebook
%pip install nest-asyncio
# Some of the systems may show the following error when running the user interface:
# urllib3 v2.0 only supports OpenSSL 1.1.1+; currently, the 'SSL' module is compiled with 'LibreSSL 2.8.3'.
# install urllib3 v1.26.6 to avoid this error
%pip install urllib3==1.26.6from datetime import datetime, timedelta
import os
import aisdb
import nest_asyncio
nest_asyncio.apply()import time
psql_conn_string = 'postgresql://USERNAME:PASSWORD@HOST:PORT/DATABASE'
start_year = 2024
end_year = 2024
start_month = 1
end_month = 12
overall_start_time = time.time()
for year in range(start_year, end_year + 1):
for month in range(start_month, end_month + 1):
print(f'Loading {year}{month:02d}')
month_start_time = time.time()
filepaths = aisdb.glob_files(f'/slow-array/Spire/{year}{month:02d}/','.zip')
filepaths = sorted([f for f in filepaths if f'{year}{month:02d}' in f])
print(f'Number of files: {len(filepaths)}')
with aisdb.PostgresDBConn(libpq_connstring=psql_conn_string) as dbconn:
try:
aisdb.decode_msgs(filepaths,
dbconn=dbconn,
source='Spire',
verbose=True,
skip_checksum=True,
raw_insertion=True,
workers=6,
timescaledb=True,
)
except Exception as e:
print(f'Error loading {year}{month:02d}: {e}')
continuefrom datetime import datetime
import aisdb
from aisdb.gis import DomainFromPoints
# Define a spatial domain centered around the point (-63.6, 44.6) with a radial distance of 50000 meters.
domain = DomainFromPoints(points=[(-63.6, 44.6)], radial_distances=[50000])
with aisdb.PostgresDBConn(libpq_connstring=psql_conn_string) as dbconn:
# Create a query object to fetch AIS data within the specified time range and spatial domain.
qry = aisdb.DBQuery(
dbconn=dbconn,
start=datetime(2023, 1, 1), end=datetime(2023, 2, 1),
xmin=domain.boundary['xmin'], xmax=domain.boundary['xmax'],
ymin=domain.boundary['ymin'], ymax=domain.boundary['ymax'],
callback=aisdb.database.sqlfcn_callbacks.in_time_bbox_validmmsi
)
# Generate rows from the query
rowgen = qry.gen_qry()
# Convert the generated rows into tracks
tracks = aisdb.track_gen.TrackGen(rowgen, decimate=False)
# Visualize the tracks on a map
aisdb.web_interface.visualize(
tracks, # The tracks (trajectories) to visualize.
domain=domain, # The spatial domain to use for the visualization.
visualearth=True, # If True, use Visual Earth for the map background.
open_browser=True # If True, automatically open the visualization in a web browser.
)encode_greatcircledistance()remove_pings_wrt_speed() drops pings recorded while a vessel was moving at or below a speed threshold, which is the fast way to strip out anchored or moored vessels.
InlandDenoising removes points that fall on land, using cached land and water geometries for North America.
The core idea behind encode_greatcircledistance() is that a real vessel's positions form a continuous, physically plausible path. When two consecutive pings imply a speed or distance that no vessel could achieve, that is a strong signal of noise, whether from a duplicate MMSI, a receiver glitch, or a spoofed identifier. The function walks each track and cuts it into segments wherever this happens.
encode_greatcircledistance() takes two thresholds to decide where to cut a track.
distance_threshold is the maximum distance, in meters, allowed between consecutive pings for them to be treated as part of the same segment.
speed_threshold is the maximum speed, in knots, a vessel can plausibly travel between consecutive pings.
Once a track is cut into candidate segments, the function does not just discard the fragments. It calls encode_score() to compute a score for every pair of segments, the score being the Haversine distance between the end of one segment and the start of the next, divided by the elapsed time between them. Segments with a short gap in both time and space score higher, meaning they are more likely to belong to the same voyage. A segment is joined to the highest-scoring candidate segment for the same MMSI, provided the score meets minscore. If no candidate segment clears that bar, the segment starts a new, independent track.
This is why encode_greatcircledistance() works well against duplicate-identifier noise. Two different vessels broadcasting the same MMSI produce position jumps that fail the distance and speed thresholds, so the encoder splits them apart. Each resulting segment is then re-linked only to segments that are geographically and temporally consistent with it, effectively separating the interleaved tracks of the two vessels back into two coherent trajectories.
The following example queries a day of traffic around Halifax harbour, splits the resulting tracks on long time gaps, then runs the great-circle-distance encoder before visualizing the result.
Processing functions like split_timedelta() and encode_greatcircledistance() can be chained this way because each one accepts a track generator and returns a track generator. Segmenting on time gaps first, then encoding, keeps the encoder from wasting effort trying to re-link pings that are already known to belong to separate voyages.
After segmentation and encoding, the tracks look like this.
For comparison, here is the same area before cleaning.
Not all noise comes from broken identifiers. Vessels sitting at anchor or moored at a berth broadcast steadily over long periods, and their pings can dominate a query or clutter a map without adding any useful trajectory information. remove_pings_wrt_speed() filters these out directly by removing any ping where the reported speed over ground (sog) is less than or equal to a given speed_threshold, expressed in knots.
Because this function only looks at reported speed, it is cheap to run early in a pipeline, before the more expensive distance and score computations in encode_greatcircledistance().
GPS drift and receiver noise occasionally place a vessel's reported position on land, which is physically impossible for anything but the smallest amphibious craft. InlandDenoising, from aisdb.denoising_encoder, checks each point in a track against cached land and water geometries and drops the ones that fall on land but not in water.
The class downloads a bundle of North American land and water geometries on first use and caches it locally, so construction takes a data directory and, optionally, the cache filenames to use.
The number after the MMSI is how many points survived the land check for that track, so a smaller count than the raw input means the denoiser found and dropped land-locked positions. The MMSIs and counts you get depend on the query window and coastline covered by your own data.
filter_noisy_points() accepts a track generator and yields cleaned tracks with the land-locked points removed, so it slots into the same pipeline pattern as the other denoising functions. Because the geometry bundle currently covers North America, this technique is most useful for AIS data collected along the Canadian and US coasts.
None of these functions require the others, so pick whichever combination matches the noise in your data. A typical pipeline for coastal AIS data looks like this.
Query and generate tracks with DBQuery and aisdb.TrackGen.
Split tracks on large time gaps with aisdb.split_timedelta().
Drop anchored vessels with aisdb.remove_pings_wrt_speed(), if stationary traffic is not of interest.
Re-link segments and separate duplicate-identifier tracks with aisdb.encode_greatcircledistance().
Optionally, remove land-locked points with InlandDenoising if the query area includes a coastline.
Visualize or export the cleaned tracks.
Each step operates on generators, so the whole pipeline stays memory-efficient even over large queries, and you can drop or reorder steps depending on what kind of noise your dataset actually has.
import aisdb
from datetime import datetime, timedelta
from aisdb import DBQuery, DomainFromPoints, SQLiteDBConn
dbpath = 'YOUR_DATABASE.db' # path to your AISdb database
# Query window
start_time = datetime.strptime('2018-01-01 00:00:00', '%Y-%m-%d %H:%M:%S')
end_time = datetime.strptime('2018-01-02 00:00:00', '%Y-%m-%d %H:%M:%S')
# A bounding box with at least a 50km radius around the given point
domain = DomainFromPoints(points=[(-63.6, 44.6)], radial_distances=[50000])
maxdelta = timedelta(hours=24) # split a track wherever the time gap exceeds this
distance_threshold = 20000 # max allowed distance (meters) between consecutive pings
speed_threshold = 50 # max plausible vessel speed (knots) between consecutive pings
minscore = 1e-6 # minimum score required to re-link two segments
with SQLiteDBConn(dbpath=dbpath) as dbconn:
qry = DBQuery(
dbconn=dbconn,
start=start_time,
end=end_time,
callback=aisdb.database.sqlfcn_callbacks.in_timerange_validmmsi,
**domain.boundary,
)
rowgen = qry.gen_qry()
tracks = aisdb.TrackGen(rowgen, decimate=False)
# Split tracks wherever the gap between pings exceeds maxdelta
track_segments = aisdb.split_timedelta(tracks, maxdelta)
# Re-link the segments using distance, speed, and score thresholds
tracks_encoded = aisdb.encode_greatcircledistance(
track_segments,
distance_threshold=distance_threshold,
speed_threshold=speed_threshold,
minscore=minscore,
)
aisdb.web_interface.visualize(
tracks_encoded,
domain=domain,
visualearth=True,
open_browser=True,
)import aisdb
with aisdb.SQLiteDBConn(dbpath=dbpath) as dbconn:
qry = aisdb.DBQuery(
dbconn=dbconn,
start=start_time,
end=end_time,
callback=aisdb.database.sqlfcn_callbacks.in_timerange_validmmsi,
)
tracks = aisdb.TrackGen(qry.gen_qry(), decimate=False)
# Drop pings recorded at 0.5 knots or slower, i.e. vessels not really moving
moving_tracks = aisdb.remove_pings_wrt_speed(tracks, speed_threshold=0.5)from aisdb.denoising_encoder import InlandDenoising
with InlandDenoising(data_dir='./inland_cache') as denoiser:
clean_tracks = denoiser.filter_noisy_points(tracks)
for track in clean_tracks:
print(track['mmsi'], track['lon'].size)316001234 187from aisdb import SQLiteDBConn
dbpath = "AIS.db"
with SQLiteDBConn(dbpath) as dbconn:
# decode_msgs, DBQuery, and custom SQL all use this connection
...import os
from aisdb import PostgresDBConn
# Option 1: keyword arguments
with PostgresDBConn(
host="127.0.0.1",
port=5432,
user="postgres",
dbname="postgres",
password=os.environ.get("POSTGRES_PASSWORD"),
) as dbconn:
...
# Option 2: a libpq connection string
with PostgresDBConn(libpq_connstring="postgresql://localhost:5432/postgres") as dbconn:
...ais_{YYYYMM}_static # table with static AIS messages
ais_{YYYYMM}_dynamic # table with dynamic AIS messagestatic_{YYYYMM}_aggregate # table of aggregated static vessel datacoarsetype_ref # a reference table that maps numeric ship type codes to their descriptionsimport sqlite3
dbpath='YOUR_DATABASE.db' # Define the path to your database
# Connect to the database
connection = sqlite3.connect(dbpath)
# Create a cursor object
cursor = connection.cursor()
# Query to list all tables
query = "SELECT name FROM sqlite_master WHERE type='table';"
cursor.execute(query)
# Fetch the results
tables = cursor.fetchall()
# Print the names of the tables
print("Tables in the database:")
for table in tables:
print(table[0])
# Close the connection
connection.close()import sqlite3
# Connect to the database
connection = sqlite3.connect('YOUR_DATABASE.db')
# Create a cursor object
cursor = connection.cursor()
# The table suffix for the month you want to query, format YYYYMM
year_month = '202401'
# Define the JOIN SQL query
query = f"""
SELECT
d.mmsi,
d.time,
d.longitude,
d.latitude,
d.sog,
d.cog,
s.vessel_name,
s.ship_type
FROM ais_{year_month}_dynamic d
LEFT JOIN ais_{year_month}_static s ON d.mmsi = s.mmsi
WHERE d.time BETWEEN 1707033659 AND 1708176856 -- Filter by time range
AND d.longitude BETWEEN -68 AND -56 -- Filter by geographical area
AND d.latitude BETWEEN 45 AND 51.5;
"""
# Execute the query
cursor.execute(query)
# Fetch the results
results = cursor.fetchall()
# Print the results
for row in results:
print(row)
# Close the connection
connection.close()(258123456, 1707033721, -63.5721, 44.6488, 12.4, 187.3, 'ATLANTIC CARRIER', 70)import sqlite3
from datetime import datetime
from aisdb.gis import dt_2_epoch
# Define the datetime range
start_datetime = datetime(2018, 1, 1, 0, 0, 0)
end_datetime = datetime(2018, 1, 1, 1, 59, 59)
# Convert datetime to epoch time
start_epoch = dt_2_epoch(start_datetime)
end_epoch = dt_2_epoch(end_datetime)
# Connect to the database
connection = sqlite3.connect('YOUR_DATABASE.db')
# Create a cursor object
cursor = connection.cursor()
# Define the JOIN SQL query using an epoch time range
query = f"""
SELECT
d.mmsi,
d.time,
d.longitude,
d.latitude,
d.sog,
d.cog,
s.vessel_name,
s.ship_type
FROM ais_201801_dynamic d
LEFT JOIN ais_201801_static s ON d.mmsi = s.mmsi
WHERE d.time BETWEEN {start_epoch} AND {end_epoch} -- Filter by time range
AND d.longitude BETWEEN -68 AND -56 -- Filter by geographical area
AND d.latitude BETWEEN 45 AND 51.5;
"""
# Execute the query
cursor.execute(query)
# Fetch the results
results = cursor.fetchall()
# Print the results
for row in results:
print(row)
# Close the connection
connection.close()from aisdb import PostgresDBConn, decode_msgs
filepaths = ["ais_data.nm4"]
with PostgresDBConn(
host="127.0.0.1",
port=5432,
user="postgres",
dbname="postgres",
password="YOUR_PASSWORD",
) as dbconn:
decode_msgs(
filepaths=filepaths,
dbconn=dbconn,
source="TimescaleExample",
verbose=True,
timescaledb=True,
)month = "202401" # table suffix, format YYYYMM
with PostgresDBConn(hostaddr="127.0.0.1", port=5432, user="postgres", dbname="postgres") as dbconn:
# Drop the mmsi/time indexes before a large bulk load
dbconn.drop_indexes(month, timescaledb=True)
# ... run decode_msgs or bulk insertion here ...
# Remove duplicate dynamic (position report) rows for the month
dbconn.deduplicate_dynamic_msgs(month)
# Rebuild the mmsi/time indexes once loading is complete
dbconn.rebuild_indexes(month, timescaledb=True)Generate estimated vessel positions between AIS reports using linear, geodesic, cubic spline, and custom interpolation methods.
Track interpolation with AISdb involves generating estimated positions of vessels at specific intervals when actual AIS data points are unavailable. This process is important for filling in gaps in the vessel's trajectory, which can occur due to signal loss, data filtering, or other disruptions.
In this tutorial, we introduce different types of track interpolation implemented in AISdb with usage examples.
First, we define functions to transform and visualize the track data (a generator object), with options to view the data points or the tracks:
import plotly.graph_objects as go
import plotly.express as px
import pandas as pd
def track2dataframe(tracks):
data = []
for track in tracks:
times = track['time']
mmsi = track['mmsi']
lons = track['lon']
lats = track['lat']
# Iterate over the dynamic arrays and create a row for each time point
for i in range(len(times)):
data.append({
'mmsi': mmsi,
'time': times[i],
'longitude': lons[i],
'latitude': lats[i],
})
return pd.DataFrame(data)
def plotly_visualize(data, visual_type='lines'):
if (visual_type=='scatter'):
# Create a scatter plot for the vessel data points using scatter_geo
fig = px.scatter_geo(
data,
lat="latitude",
lon="longitude",
color="mmsi", # Color by vessel identifier
hover_name="mmsi",
hover_data={"time": True},
title="Vessel Data Points"
)
else:
# Create a line plot for the vessel trajectory using line_geo
fig = px.line_geo(
data,
lat="latitude",
lon="longitude",
color="mmsi", # Color by vessel identifier
hover_name="mmsi",
hover_data={"time": True},
title="Vessel Trajectory"
)
# Set the map style and projection
fig.update_geos(
projection_type="azimuthal equal area", # Change this to 'natural earth', 'azimuthal equal area', etc.
showland=True,
landcolor="rgb(243, 243, 243)",
countrycolor="rgb(204, 204, 204)",
)
# Set the layout to focus on a specific area or zoom level
fig.update_layout(
geo=dict(
projection_type="azimuthal equal area",
),
width=1200, # Increase the width of the plot
height=800, # Increase the height of the plot
)
fig.show()We will use an actual track retrieved from the database for the examples in this tutorial and interpolate additional data points based on this track. The visualization will show the original track data points:
import aisdb
import numpy as np
import nest_asyncio
from aisdb import DBConn, DBQuery
from datetime import timedelta, datetime
nest_asyncio.apply()
dbpath='YOUR_DATABASE.db' # Define the path to your database
MMSI = 636017611 # MMSI of the vessel
# Set the start and end times for the query
start_time = datetime.strptime("2018-03-10 00:00:00", '%Y-%m-%d %H:%M:%S')
end_time = datetime.strptime("2018-03-31 00:00:00", '%Y-%m-%d %H:%M:%S')
with aisdb.SQLiteDBConn(dbpath=dbpath) as dbconn:
qry = aisdb.DBQuery(
dbconn=dbconn, start=start_time, end=end_time, mmsi = MMSI,
callback=aisdb.database.sqlfcn_callbacks.in_timerange_hasmmsi,
)
rowgen = qry.gen_qry()
tracks = aisdb.track_gen.TrackGen(rowgen, decimate=False)
# Visualize the original track data points
df = track2dataframe(tracks)
plotly_visualize(df, 'scatter')Linear interpolation estimates the vessel's position by drawing a straight line between two known points and calculating the positions at intermediate times. It is simple, fast, and straightforward but may not accurately represent complex movements.
This method estimates the position of a vessel at regular time intervals (e.g., every 10 minutes). To perform linear interpolation with an equal time window on the track defined above:
This method estimates the position of a vessel at regular spatial intervals (e.g., every 1 km along its path). To perform linear interpolation with equal distance intervals on the track defined above:
This method estimates the positions of a vessel along a curved path using the principles of geometry, in particular great-circle routes.
Given a set of data points, cubic spline interpolation fits a smooth curve through these points. The curve is represented as a series of cubic polynomials between each pair of data points. Each polynomial ensures a smooth curve at the data points (i.e., the first and second derivatives are continuous).
In addition to the standard interpolation methods provided by AISdb, users can implement other interpolation techniques tailored to their specific analytical needs. For instance, B-spline (Basis Spline) interpolation is a mathematical technique that creates a smooth curve through data points. This smoothness is important in trajectory analysis as it avoids sharp, unrealistic turns and maintains a natural flow.
Here is an implementation and example of using B-spline interpolation:
Then, we can apply the function we just implemented to the vessel track generator:
The visualization of the interpolation is shown below.
Linear interpolation is the right default when speed matters more than geometric precision; it is cheap to compute and good enough for short gaps. For long ocean legs where a straight line would cut across the curvature of the Earth, geodesic interpolation follows the great-circle route instead. Cubic spline interpolation is worth reaching for when the downstream analysis is sensitive to smoothness, such as estimating heading or turn rate, since it avoids the sharp kinks a linear fit produces at each waypoint.
Whichever method is chosen, interpolation invents positions between what the receiver actually reported. Feeding it a noisy or duplicated track will smooth over errors rather than correct them, so denoise the track first.
See for removing bad pings and duplicate reports before interpolating a track.
Query AIS data loaded with AISdb using DBQuery and TrackGen, with practical examples for time range, bounding box, and MMSI filters.
Data querying with AISdb involves setting up a connection to the database, defining query parameters, creating and executing the query, and processing the results. Following the previous tutorial, Database Loading, we set up a database connection and made simple queries and visualizations. This tutorial will dig into data query functions and parameters and show you the queries you can make with AISdb.
Data querying with AISdb involves two components, DBQuery and TrackGen. In this section, we will introduce each component with examples. Before starting data querying, please ensure you have connected to the database. If you have not done so, please follow the instructions and examples in Database Loading or Quick Start.
The DBQuery class is used to create a query object that specifies the parameters for data retrieval, including the time range, spatial domain, and any filtering callbacks. Here is an example of creating a DBQuery object and using parameters to specify the time range and geographical locations:
import aisdb
# Specify database path
dbpath = 'YOUR_DATABASE.db'
# Specify constraints (optional)
start_time = ...
end_time = ...
domain = ...
with aisdb.SQLiteDBConn(dbpath=dbpath) as dbconn:
# Create a query object to fetch data within time and geographical range
qry = aisdb.DBQuery(
dbconn=dbconn, # Database connection object
start=start_time, # Start time for the query
end=end_time, # End time for the query
xmin=domain.boundary['xmin'], # Minimum longitude of the domain
xmax=domain.boundary['xmax'], # Maximum longitude of the domain
ymin=domain.boundary['ymin'], # Minimum latitude of the domain
ymax=domain.boundary['ymax'], # Maximum latitude of the domain
callback=aisdb.database.sqlfcn_callbacks.in_time_bbox_validmmsi, # Callback function to filter data
)Callback functions are used in the DBQuery class to filter data based on specific criteria. Some common callbacks include in_validmmsi_bbox, in_time_bbox, in_timerange_validmmsi, and in_time_bbox_validmmsi. These callbacks ensure that the data retrieved matches the specific criteria defined in the query. Please find examples of using different callbacks with other parameters in .
For more callback functions, refer to the API documentation here:
The method of the class runs the query and streams back the matching data. It does not yield one AIS record at a time. Instead, it is a generator that yields a batch of rows for each unique MMSI, sorted by MMSI first and then by time within each batch, fetching results in chunks under the hood so large queries stay memory-efficient. That grouping is exactly what TrackGen expects downstream, since a "track" is just a time-ordered run of positions for one vessel.
The SQL-generating function used internally is controlled by the fcn argument. AISdb ships two of them in aisdb.database.sqlfcn.
crawl_dynamic iterates only over the position reports table. This is the default.
crawl_dynamic_static iterates over both the position reports and static message tables, so each row also carries fields such as vessel name and ship type.
After creating the DBQuery object, generate the grouped rows with gen_qry.
Each element of mmsi_rows is a sqlite3.Row (or a dict-like row when querying PostgreSQL) representing one position report. In practice you will rarely iterate gen_qry directly. Pass it straight to TrackGen, which does this grouping work for you and returns ready-to-use track dictionaries.
The class converts the generated rows from gen_qry into tracks (trajectories). It takes the row generator and, optionally, a decimate parameter to control point reduction. This conversion is essential for analyzing vessel movements, identifying patterns, and visualizing trajectories in later steps.
Following the generated rows above, here is how to use the TrackGen class:
The TrackGen class returns a generator object that yields "tracks." While iterating over the tracks, each item is a dictionary representing the track of a specific vessel:
This is the output with our sample data:
In this section, we will provide practical examples of the most common query types you can make using the DBQuery class, including querying within a time range, querying within a geographical area, and tracking vessels by MMSI. Different queries can be achieved by changing the parameter and other parameters defined in the DBQuery class. Then, we will use TrackGen to convert these query results into structured tracks for further analysis and visualization.
First, we need to import the necessary packages and prepare data:
Querying data within a specified time range can be done by using the callback in the DBQuery class:
This will display the queried vessel tracks (within a time range, with a valid MMSI) on the map:
You may find noise in some of the track data. In , we introduced the de-noising methods in AISdb that can effectively remove unreasonable or erroneous data points, ensuring more accurate and reliable vessel trajectories.
In practical scenarios, people may have specific points/areas of interest. DBQuery includes parameters to define a bounding box and has relevant callbacks. Let's look at an example:
This will show all the vessel tracks with valid MMSI in the defined bounding box:
In the above examples, we queried data in a time range and a geographical area. If you want to combine multiple query criteria, please check out available in the API Docs. In the last example above, we can simply modify the callback type to obtain vessel tracks within both the time range and geographical area:
The displayed vessel tracks:
In addition to time and location range, you can track one or more vessels of interest by specifying their MMSIs in the query. Here is an example of tracking several vessels within a time range:
The tracks pulled here still carry the noise inherent to raw AIS, duplicate points, GPS jumps, and implausible speeds among them. See for denoising them before further analysis. Once a track is clean, covers plotting it on a map alongside other vessels and points of interest.
Calculate vessel speed over ground in knots between consecutive AIS positions, and use it to flag implausible track jumps.
In AISdb, the speed of a vessel is calculated using the aisdb.gis.delta_knots function, which computes the speed over ground (SOG) in knots between consecutive positions within a given track. This calculation matters for the denoising encoder, which compares a vessel's speed against a set threshold to flag implausible jumps in the data.
Vessel speed calculation requires the distance the vessel has traveled between two consecutive positions and the time interval between them. The distance is computed using the haversine distance function (aisdb.gis.delta_meters), and the time interval comes from aisdb.gis.delta_seconds, which takes the difference between consecutive timestamps in a track's time array. The speed is then computed using the formula:
The factor 1.9438445 converts speed from meters per second to knots, the standard speed unit used in maritime contexts. Internally, delta_knots clamps each elapsed-time value to a minimum of one second before dividing, so a duplicate or near-duplicate timestamp in a track can't produce a division by zero or an unrealistically inflated speed.
With the example track we created in , we can calculate the vessel speed between each pair of consecutive positions:
The four values correspond to the four gaps between the five positions in the track. The first three positions are an hour apart and produce speeds around 3.8 knots, consistent with a vessel moving at a leisurely pace. The gap between the fourth and fifth positions spans three hours and covers a much greater distance, which is why the last value jumps to roughly 10.3 knots. This is exactly the kind of signal aisdb.denoising_encoder.remove_pings_wrt_speed relies on when filtering a track. A speed that spikes far above what a vessel of that type could plausibly sustain usually points to a corrupted or duplicated AIS position report rather than real movement.
mmsi
INTEGER
Maritime Mobile Service Identity, a unique identifier for vessels.
time
INTEGER
Timestamp of the AIS message, in epoch seconds.
longitude
REAL
Longitude of the vessel in decimal degrees.
latitude
REAL
Latitude of the vessel in decimal degrees.
rot
REAL
Rate of turn, indicating how fast the vessel is turning.
sog
REAL
Speed over ground, in knots.
cog
REAL
Course over ground, in degrees.
heading
REAL
Heading of the vessel, in degrees.
maneuver
BOOLEAN
Indicator for whether the vessel is performing a special maneuver.
utc_second
INTEGER
Second of the UTC timestamp when the message was generated.
source
TEXT
Source of the AIS data.
mmsi
INTEGER
Maritime Mobile Service Identity, a unique identifier for vessels.
time
INTEGER
Timestamp of the AIS message, in epoch seconds.
vessel_name
TEXT
Name of the vessel.
ship_type
INTEGER
Numeric code representing the type of ship.
call_sign
TEXT
International radio call sign of the vessel.
imo
INTEGER
International Maritime Organization number, another unique vessel identifier.
dim_bow
INTEGER
Distance from the AIS transmitter to the bow (front) of the vessel.
dim_stern
INTEGER
Distance from the AIS transmitter to the stern (back) of the vessel.
dim_port
INTEGER
Distance from the AIS transmitter to the port (left) side of the vessel.
dim_star
INTEGER
Distance from the AIS transmitter to the starboard (right) side of the vessel.
draught
REAL
Maximum depth of the vessel's hull below the waterline, in meters.
destination
TEXT
Destination port or location where the vessel is heading.
ais_version
INTEGER
AIS protocol version used by the vessel.
fixing_device
TEXT
Type of device used for fixing the vessel's position (e.g., GPS).
eta_month
INTEGER
Estimated time of arrival month.
eta_day
INTEGER
Estimated time of arrival day.
eta_hour
INTEGER
Estimated time of arrival hour.
eta_minute
INTEGER
Estimated time of arrival minute.
source
TEXT
Source of the AIS data (e.g., specific AIS receiver or data provider).
mmsi
INTEGER
Maritime Mobile Service Identity, a unique identifier for vessels.
imo
INTEGER
International Maritime Organization number, another unique vessel identifier.
vessel_name
TEXT
Name of the vessel.
ship_type
INTEGER
Numeric code representing the type of ship.
call_sign
TEXT
International radio call sign of the vessel.
dim_bow
INTEGER
Distance from the AIS transmitter to the bow (front) of the vessel.
dim_stern
INTEGER
Distance from the AIS transmitter to the stern (back) of the vessel.
dim_port
INTEGER
Distance from the AIS transmitter to the port (left) side of the vessel.
dim_star
INTEGER
Distance from the AIS transmitter to the starboard (right) side of the vessel.
draught
REAL
Maximum depth of the vessel's hull below the waterline, in meters.
destination
TEXT
Destination port or location where the vessel is heading.
eta_month
INTEGER
Estimated time of arrival month.
eta_day
INTEGER
Estimated time of arrival day.
eta_hour
INTEGER
Estimated time of arrival hour.
eta_minute
INTEGER
Estimated time of arrival minute.












with aisdb.SQLiteDBConn(dbpath=dbpath) as dbconn:
qry = aisdb.DBQuery(
dbconn=dbconn, start=start_time, end=end_time, mmsi = MMSI,
callback=aisdb.database.sqlfcn_callbacks.in_timerange_hasmmsi,
)
rowgen = qry.gen_qry()
tracks = aisdb.track_gen.TrackGen(rowgen, decimate=False)
tracks__ = aisdb.interp.interp_time(tracks, timedelta(minutes=10))
df = track2dataframe(tracks__)
plotly_visualize(df)with aisdb.SQLiteDBConn(dbpath=dbpath) as dbconn:
qry = aisdb.DBQuery(
dbconn=dbconn, start=start_time, end=end_time, mmsi = MMSI,
callback=aisdb.database.sqlfcn_callbacks.in_timerange_hasmmsi,
)
rowgen = qry.gen_qry()
tracks = aisdb.track_gen.TrackGen(rowgen, decimate=False)
tracks__ = aisdb.interp.interp_spacing(spacing=500, tracks=tracks)
# Visualizing the tracks
df = track2dataframe(tracks__)
plotly_visualize(df)with aisdb.SQLiteDBConn(dbpath=dbpath) as dbconn:
qry = aisdb.DBQuery(
dbconn=dbconn, start=start_time, end=end_time, mmsi = MMSI,
callback=aisdb.database.sqlfcn_callbacks.in_timerange_hasmmsi,
)
rowgen = qry.gen_qry()
tracks = aisdb.track_gen.TrackGen(rowgen, decimate=False)
tracks__ = aisdb.interp.geo_interp_time(tracks, timedelta(minutes=10))
df = track2dataframe(tracks__)
plotly_visualize(df)with aisdb.SQLiteDBConn(dbpath=dbpath) as dbconn:
qry = aisdb.DBQuery(
dbconn=dbconn, start=start_time, end=end_time, mmsi = MMSI,
callback=aisdb.database.sqlfcn_callbacks.in_timerange_hasmmsi,
)
rowgen = qry.gen_qry()
tracks = aisdb.track_gen.TrackGen(rowgen, decimate=False)
tracks__ = aisdb.interp.interp_cubic_spline(tracks, timedelta(minutes=10))
# Visualizing the tracks
df = track2dataframe(tracks__)
plotly_visualize(df)import warnings
import numpy as np
from scipy.interpolate import splrep, splev
def bspline_interpolation(track, key, intervals):
"""
Perform B-Spline interpolation for a specific key on the track data.
Parameters:
- track: Dictionary containing vessel track data (time, lat, lon, etc.).
- key: The dynamic key (e.g., 'lat', 'lon') for which interpolation is performed.
- intervals: The equal time or distance intervals at which interpolation is required.
Returns:
- Interpolated values for the specified key.
"""
# Get time and the key values (e.g., lat/lon) for interpolation
times = track['time']
values = track[key]
# Create the B-Spline representation of the curve
tck = splrep(times, values, s=0) # s=0 means no smoothing, exact fit to data
# Interpolate the values at the given intervals
interpolated_values = splev(intervals, tck)
return interpolated_values
def interp_bspline(tracks, step=1000):
"""
Perform B-Spline interpolation on vessel trajectory data at equal time intervals.
Parameters:
- tracks: List of vessel track dictionaries.
- step: Step for interpolation (can be time or distance-based).
Yields:
- Dictionary containing interpolated lat and lon values for each track.
"""
for track in tracks:
if len(track['time']) <= 1:
warnings.warn('Cannot interpolate track of length 1, skipping...')
continue
# Generate equal time intervals based on the first and last time points
intervals = np.arange(track['time'][0], track['time'][-1], step)
# Perform B-Spline interpolation for lat and lon
interpolated_lat = bspline_interpolation(track, 'lat', intervals)
interpolated_lon = bspline_interpolation(track, 'lon', intervals)
# Yield interpolated track
itr = dict(
mmsi=track['mmsi'],
lat=interpolated_lat,
lon=interpolated_lon,
time=intervals # Including interpolated time intervals for reference
)
yield itrwith aisdb.SQLiteDBConn(dbpath=dbpath) as dbconn:
qry = aisdb.DBQuery(
dbconn=dbconn, start=start_time, end=end_time, mmsi = MMSI,
callback=aisdb.database.sqlfcn_callbacks.in_timerange_hasmmsi,
)
rowgen = qry.gen_qry()
tracks = aisdb.track_gen.TrackGen(rowgen, decimate=False)
tracks__ = interp_bspline(tracks)
# Visualizing the tracks
df = track2dataframe(tracks__)
plotly_visualize(df)from aisdb.database import sqlfcn
# Generate rows from the query, grouped by MMSI
rowgen = qry.gen_qry(fcn=sqlfcn.crawl_dynamic_static) # fcn is optional, defaults to crawl_dynamic
# Each item is the list of rows belonging to a single vessel
for mmsi_rows in rowgen:
print(f"{len(mmsi_rows)} rows for MMSI {mmsi_rows[0]['mmsi']}")from aisdb.track_gen import TrackGen
# Convert the generated rows into tracks
tracks = TrackGen(rowgen, decimate=False)for track in tracks:
mmsi = track['mmsi']
lons = track['lon']
lats = track['lat']
speeds = track['sog']
print(f"Track for vessel MMSI {mmsi}:")
for lon, lat, speed in zip(lons[:3], lats[:3], speeds[:3]):
print(f" - Lon: {lon}, Lat: {lat}, Speed: {speed}")
break # Exit after the first trackTrack for vessel MMSI 316004240:
- Lon: -63.54868698120117, Lat: 44.61691665649414, Speed: 7.199999809265137
- Lon: -63.54880905151367, Lat: 44.61708450317383, Speed: 7.099999904632568
- Lon: -63.55659866333008, Lat: 44.626953125, Speed: 1.5import aisdb
from datetime import datetime
from aisdb import DomainFromPoints
dbpath = 'YOUR_DATABASE.db' # Define the path to your databasestart_time = datetime.strptime("2018-01-01 00:00:00", '%Y-%m-%d %H:%M:%S')
end_time = datetime.strptime("2018-01-02 00:00:00", '%Y-%m-%d %H:%M:%S')
with aisdb.SQLiteDBConn(dbpath=dbpath) as dbconn:
qry = aisdb.DBQuery(
dbconn=dbconn, start=start_time, end=end_time,
callback=aisdb.database.sqlfcn_callbacks.in_timerange_validmmsi,
)
rowgen = qry.gen_qry()
# Convert queried rows to vessel trajectories
tracks = aisdb.track_gen.TrackGen(rowgen, decimate=False)
# Visualization
aisdb.web_interface.visualize(
tracks,
visualearth=True,
open_browser=True,
)domain = DomainFromPoints(points=[(-63.6, 44.6)], radial_distances=[50000]) # a bounding box extending at least 50km from the point
with aisdb.SQLiteDBConn(dbpath=dbpath) as dbconn:
qry = aisdb.DBQuery(
dbconn=dbconn, start=start_time, end=end_time,
xmin=domain.boundary['xmin'], xmax=domain.boundary['xmax'],
ymin=domain.boundary['ymin'], ymax=domain.boundary['ymax'],
callback=aisdb.database.sqlfcn_callbacks.in_validmmsi_bbox,
)
rowgen = qry.gen_qry()
tracks = aisdb.track_gen.TrackGen(rowgen, decimate=False)
aisdb.web_interface.visualize(
tracks,
domain=domain,
visualearth=True,
open_browser=True,
)callback=aisdb.database.sqlfcn_callbacks.in_time_bbox_validmmsiimport random
def assign_colors(mmsi_list):
colors = {}
for mmsi in mmsi_list:
colors[mmsi] = "#{:06x}".format(random.randint(0, 0xFFFFFF)) # Random color in hex
return colors
# Create a function to color tracks
def color_tracks(tracks, colors):
colored_tracks = []
for track in tracks:
mmsi = track['mmsi']
color = colors.get(mmsi, "#000000") # Default to black if no color assigned
track['color'] = color
colored_tracks.append(track)
return colored_tracks
# Set the start and end times for the query
start_time = datetime.strptime("2018-01-01 00:00:00", '%Y-%m-%d %H:%M:%S')
end_time = datetime.strptime("2018-12-31 00:00:00", '%Y-%m-%d %H:%M:%S')
# Create a list of vessel MMSIs you want to track
MMSI = [636017611,636018124,636018253]
# Assign colors to each MMSI
colors = assign_colors(MMSI)
with aisdb.SQLiteDBConn(dbpath=dbpath) as dbconn:
qry = aisdb.DBQuery(
dbconn=dbconn, start=start_time, end=end_time, mmsis=MMSI,
callback=aisdb.database.sqlfcn_callbacks.in_timerange_inmmsi,
)
rowgen = qry.gen_qry()
tracks = aisdb.track_gen.TrackGen(rowgen, decimate=False)
colored_tracks = color_tracks(tracks, colors)
# Visualizing the tracks
aisdb.web_interface.visualize(
colored_tracks,
visualearth=True,
open_browser=True,
)import aisdb
import numpy as np
from datetime import datetime
from aisdb.gis import dt_2_epoch
# Generate example track
y1, x1 = 44.57039426840729, -63.52931373766157
y2, x2 = 44.51304767533133, -63.494075674952555
y3, x3 = 44.458038982492134, -63.535634138077945
y4, x4 = 44.393941339104074, -63.53826396955358
y5, x5 = 44.14245580737021, -64.16608964280064
t1 = dt_2_epoch( datetime(2021, 1, 1, 1) )
t2 = dt_2_epoch( datetime(2021, 1, 1, 2) )
t3 = dt_2_epoch( datetime(2021, 1, 1, 3) )
t4 = dt_2_epoch( datetime(2021, 1, 1, 4) )
t5 = dt_2_epoch( datetime(2021, 1, 1, 7) )
# Create a sample track
tracks_short = [
dict(
mmsi=123456789,
lon=np.array([x1, x2, x3, x4, x5]),
lat=np.array([y1, y2, y3, y4, y5]),
time=np.array([t1, t2, t3, t4, t5]),
dynamic=set(['lon', 'lat', 'time']),
static=set(['mmsi'])
)
]
# Calculate the vessel speed in knots
for track in tracks_short:
print(aisdb.gis.delta_knots(track))[3.7588560005768947 3.7519408684140214 3.8501088005116215 10.309565520121597]workers (int, optional)
Number of parallel worker processes used to decode files.
Default: 4.
type_preference (str, optional)
Which AIS message types to keep during decoding ("all", "static", or "dynamic").
Default: "all".
raw_insertion (bool, optional)
If True, rows are inserted without maintaining indexes as you go, which is significantly faster for bulk loads. Indexes get (re)built afterward. Set to False only for small, incremental inserts into an already-indexed table.
Default: True.
skip_checksum (bool, optional)
If True, skips the MD5 checksum lookup AISdb otherwise uses to avoid re-ingesting a file it has already processed.
Default: True.
vacuum (bool, optional)
If True, runs a VACUUM on the database after insertion to reclaim space and update planner statistics.
Default: False.
timescaledb (bool, optional)
Set to True only if using the TimescaleDB extension in your PostgreSQL database, which structures dynamic tables as hypertables instead of the original per-month B-Tree indexed tables.
Default: False. Refer to the for proper setup and usage.
from aisdb.database.dbconn import PostgresDBConn
# Option 1: Using keyword arguments
dbconn = PostgresDBConn(
hostaddr='127.0.0.1', # Replace with the PostgreSQL address
port=5432, # Replace with the PostgreSQL running port
user='USERNAME', # Replace with the PostgreSQL username
password='PASSWORD', # Replace with your password
dbname='aisviz' # Replace with your database name
)
# Option 2: Using a connection string
dbconn = PostgresDBConn('postgresql://USERNAME:PASSWORD@HOST:PORT/DATABASE')

Visualize AISdb vessel tracks with the built-in web interface or with Contextily, Basemap, Cartopy, Plotly, and Kepler.gl.
This tutorial covers the visualization options available for vessel trajectories processed with AISdb, including AISdb's integrated web interface and alternative approaches built on popular Python visualization packages. Each tool comes with a working example, so you can see exactly how to turn queried AISdb tracks into a map.
AISdb provides an integrated data visualization feature through the aisdb.web_interface.visualize module, which allows users to generate interactive maps displaying vessel tracks. This built-in tool is designed for simplicity and ease of use, offering customizable visualizations directly from AIS data without requiring extensive setup.
Here is an example of using the web interface module to show queried data with colors. To display vessel tracks in a single color:
import aisdb
from datetime import datetime
from aisdb.database.dbconn import SQLiteDBConn
from aisdb import DBConn, DBQuery, DomainFromPoints
import nest_asyncio
nest_asyncio.apply()
dbpath='YOUR_DATABASE.db' # Define the path to your database
# Set the start and end times for the query
start_time = datetime.strptime("2018-01-01 00:00:00", '%Y-%m-%d %H:%M:%S')
end_time = datetime.strptime("2018-01-03 00:00:00", '%Y-%m-%d %H:%M:%S')
# Define a circle with a 100km radius around the location point
domain = DomainFromPoints(points=[(-63.6, 44.6)], radial_distances=[100000])
def color_tracks(tracks):
""" Set the color of each vessel track using a color name or RGB value. """
for track in tracks:
track['color'] = 'yellow'
yield track
with aisdb.SQLiteDBConn(dbpath=dbpath) as dbconn:
qry = aisdb.DBQuery(
dbconn=dbconn, start=start_time, end=end_time,
xmin=domain.boundary['xmin'], xmax=domain.boundary['xmax'],
ymin=domain.boundary['ymin'], ymax=domain.boundary['ymax'],
callback=aisdb.database.sqlfcn_callbacks.in_time_bbox_validmmsi,
)
rowgen = qry.gen_qry()
tracks = aisdb.track_gen.TrackGen(rowgen, decimate=False)
colored_tracks = color_tracks(tracks)
# Visualization
aisdb.web_interface.visualize(
colored_tracks,
domain=domain,
visualearth=True,
open_browser=True,
)If you want to visualize vessel tracks in different colors based on MMSI, here's an example that demonstrates how to color-code tracks for easy identification:
import random
def color_tracks2(tracks):
colors = {}
for track in tracks:
mmsi = track.get('mmsi')
if mmsi not in colors:
# Assign a random color to this MMSI if not already assigned
colors[mmsi] = "#{:06x}".format(random.randint(0, 0xFFFFFF))
track['color'] = colors[mmsi] # Set the color for the current track
yield track
with aisdb.SQLiteDBConn(dbpath=dbpath) as dbconn:
qry = aisdb.DBQuery(
dbconn=dbconn, start=start_time, end=end_time,
xmin=domain.boundary['xmin'], xmax=domain.boundary['xmax'],
ymin=domain.boundary['ymin'], ymax=domain.boundary['ymax'],
callback=aisdb.database.sqlfcn_callbacks.in_time_bbox_validmmsi,
)
rowgen = qry.gen_qry()
tracks = aisdb.track_gen.TrackGen(rowgen, decimate=False)
colored_tracks = list(color_tracks2(tracks))
# Visualization
aisdb.web_interface.visualize(
colored_tracks,
domain=domain,
visualearth=True,
open_browser=True,
)If you need more advanced or specialized visualization, several Python packages pair well with AISdb tracks. Contextily, Basemap, and Cartopy are solid choices for detailed 2D plots, while Plotly gives you interactive, web-based graphs. Kepler.gl is the better fit for large-scale or 3D visualizations. Each package handles the same track data differently, so pick whichever fits how you want to present and explore your AIS data.
Note: mpl_toolkits.basemap uses numpy v1, so downgrade numpy to v1.26.4 to use Basemap, or turn to one of the other alternatives mentioned here, such as Contextily.
Which tool to reach for depends on what you're checking. web_interface.visualize is the fastest way to eyeball a query while you're still iterating on it, since it needs no extra dependencies beyond AISdb itself. Matplotlib with Contextily or Cartopy is the right choice once you need a static figure for a report or paper, where projection control and print quality matter more than interactivity. Kepler.gl is built for the opposite case, large track sets that need to be filtered, layered, and explored interactively rather than viewed once and discarded.
Once tracks are visualized, covers filling gaps between AIS position reports so the tracks you plot are continuous rather than jumping between sparse pings.
Compute great-circle distance between AIS positions with the haversine formula, including 3D distance to a submerged reference point.
Two AIS positions are almost never on a flat plane. A vessel moving between consecutive reports is tracing an arc across a curved Earth, and treating that arc as a straight line introduces error that grows with distance. AISdb solves this with the haversine formula, which computes great-circle distance directly from pairs of latitude/longitude coordinates. The formula itself is implemented in Rust for speed and exposed to Python as aisdb.gis.delta_meters, which calculates the haversine distance in meters between consecutive positions within a vessel track. This is the building block behind several other AISdb calculations, including vessel speed and the denoising encoder, which compares consecutive-position distances against a threshold to help flag noisy AIS pings.
Here is an example of calculating the haversine distance between each pair of consecutive points on a track:
import aisdb
import numpy as np
from aisdb.gis import dt_2_epoch
from datetime import datetime
y1, x1 = 44.57039426840729, -63.52931373766157
y2, x2 = 44.51304767533133, -63.494075674952555
y3, x3 = 44.458038982492134, -63.535634138077945
y4, x4 = 44.393941339104074, -63.53826396955358
y5, x5 = 44.14245580737021, -64.16608964280064
t1 = dt_2_epoch( datetime(2021, 1, 1, 1) )
t2 = dt_2_epoch( datetime(2021, 1, 1, 2) )
t3 = dt_2_epoch( datetime(2021, 1, 1, 3) )
t4 = dt_2_epoch( datetime(2021, 1, 1, 4) )
t5 = dt_2_epoch( datetime(2021, 1, 1, 7) )
# Create a sample track
tracks_short = [
dict(
lon=np.array([x1, x2, x3, x4, x5]),
lat=np.array([y1, y2, y3, y4, y5]),
time=np.array([t1, t2, t3, t4, t5]),
mmsi=123456789,
dynamic=set(['lon', 'lat', 'time']),
static=set(['mmsi'])
)
]
# Calculate the Haversine distance
for track in tracks_short:
print(aisdb.gis.delta_meters(track))[ 6961.401286 6948.59446128 7130.40147082 57279.94580704]delta_meters returns one value fewer than the number of positions in the track, because each output is the distance between a position and the one that follows it. The last leg (57,279 meters) is noticeably longer than the first three, which makes sense given the three-hour gap between t4 and t5 in the timestamps above, plenty of time for a vessel to cover more ground.
If we visualize this track on the map, we can observe:
The haversine formula only accounts for horizontal distance across the Earth's surface. When the reference point isn't at sea level, such as a hydrophone, a pipeline, or any other piece of subsea infrastructure, AISdb adds a vertical (depth) component using aisdb.gis.distance3D. It combines the haversine distance with the depth via the Pythagorean theorem, treating the depth as the third leg of a right triangle:
aisdb.gis.vesseltrack_3D_dist applies this calculation across an entire track, appending the result to each position under a new dynamic key (distance_metres by default). Reusing the track from above, here is the distance from each position to a fixed subsea point 150 meters below the surface, near the entrance to Halifax Harbour:
Unlike delta_meters, this returns one value per position rather than one per leg, since each distance is measured from the fixed subsea point rather than between consecutive track positions.
Download open-source AIS data or import your own files into AISdb, from a raw CSV through database loading, querying, cleaning, and processing.
In addition to accessing data stored on the AISdb server, you can download open-source AIS data or import your own datasets for processing and analysis using AISdb. This tutorial walks through the full pipeline for working with your own AIS files, from downloading a raw CSV and loading it into a database, through querying, cleaning, and processing the resulting tracks. We provide two loading examples, Downloading and Processing Individual Files, which demonstrates working with a small data sample and a SQLite database, and Pipeline for Bulk File Downloads and Database Integration, which outlines our approach to handling multiple data file downloads and a PostgreSQL database. From there, the tutorial covers querying your database with DBQuery, cleaning the resulting tracks, and processing them into a usable form.
U.S. vessel traffic data across user-defined geographies and periods is available at MarineCadastre. This resource offers comprehensive AIS data that can be accessed for various maritime analysis purposes. We can tailor the dataset based on research needs by selecting specific regions and timeframes.
In the following example, we will show how to download and process a single data file and import the data to a newly created SQLite database.
First, download the AIS data for a single day using the curl command:
curl -o ./data/AIS_2020_01_01.zip https://coast.noaa.gov/htdata/CMSP/AISDataHandler/2020/AIS_2020_01_01.zipThen, extract the downloaded ZIP file to a specific path:
We will look at the columns in the downloaded CSV file.
The required columns for AISdb have specific names and may differ from those in the imported dataset. Therefore, let's define the exact list of columns needed.
Next, we update the column names in the existing dataframe df_ and change the time format as required. The timestamp of an AIS message is represented by BaseDateTime in the default format YYYY-MM-DDTHH:MM:SS. For AISdb, however, the time is represented in UNIX format. We now read the CSV and apply the necessary changes to the date format:
In the code, we can see that we have mapped the column names accordingly. The data type of some columns has also been changed. An nm4 file usually contains raw messages with a Message_ID separating static messages from dynamic ones; however, the MarineCadastre data does not have such an indicator of message type. Thus, adding static messages is necessary for database creation so that a table related to metadata is created.
Let's process the CSV to create an SQLite database using the aisdb package.
A SQLite database has now been created.
If you would rather load the same file into PostgreSQL, connect with PostgresDBConn instead of SQLiteDBConn and pass the same filepaths list to decode_msgs. PostgresDBConn accepts either a set of keyword arguments or a libpq connection string.
The keyword-argument form is equivalent and often easier to keep in a config file or environment variables.
Everything downstream, querying, cleaning, and processing, works identically against a PostgreSQL connection, since DBQuery accepts any SQLiteDBConn or PostgresDBConn object.
Once your file is decoded and sitting in a database, the loading step is done and the rest of the pipeline is the same regardless of where the data came from. Querying uses DBQuery to select rows by time range and location, then TrackGen to reshape those rows into per-vessel track dictionaries. Both classes are covered in more depth in ; here we apply them directly to the SQLite database created above.
in_timerange_validmmsi restricts the query to the given window and drops rows with a malformed MMSI, which is a sensible default when the source data has not been cleaned yet. gen_qry() yields rows sorted by MMSI and time, and TrackGen collapses those rows into one dictionary per vessel, with decimate=True applying linear curve decimation to drop redundant points along straight segments.
Data pulled from a real-world source such as MarineCadastre or your own receiver will contain some amount of noise, duplicate MMSIs, anchored vessels, and the occasional impossible jump in position. AISdb's denoising_encoder module handles this. The tutorial covers the module in detail; a minimal pipeline on the tracks queried above looks like this.
remove_pings_wrt_speed() and encode_greatcircledistance() both accept and return a track generator, so they chain in whatever order suits the noise in your data. Since these are generators, nothing is computed until the tracks are consumed downstream.
With the tracks queried and cleaned, the last step is turning them into something usable, whether that is an interpolated trajectory, a CSV file, or a map. aisdb.interp_time() resamples a track to a fixed time step, which is useful for aligning trajectories to a common temporal grid before further analysis.
To save the processed tracks to disk, aisdb.write_csv() writes each track's column vectors as rows in a CSV file.
Or, to inspect the result visually instead, pass the tracks straight to aisdb.web_interface.visualize(), the same way earlier tutorials do.
That covers the full pipeline for your own AIS files, loading raw messages into a database with decode_msgs, querying them back out with DBQuery and TrackGen, cleaning the result with the denoising_encoder functions, and processing the cleaned tracks into an interpolated trajectory, a CSV export, or a visualization.
This section provides an example of downloading and processing multiple files and loading the data into an AISdb-aligned database. The pipeline is packaged as the noaa-integrator command-line tool, available in this . Each stage is a subcommand; run the stages you need, in order.
Clone the repository and sync its environment with :
The download stage fetches AIS archives from for the years you specify, the organize stage groups them into {year}{month} folders, and the extract stage decompresses them. All NOAA formats are handled, including daily ZIP archives (2015-2024) and Zstandard-compressed CSV (2025 onward).










The load stage decodes the CSV files into an AISdb database through aisdb.decode_msgs, one month batch at a time, with automatic retries for failed batches. Both SQLite and PostgreSQL targets are supported; PostgreSQL credentials come from the --dsn argument or the NOAA_PG_DSN environment variable:
import aisdb
from datetime import datetime
from aisdb.database.dbconn import SQLiteDBConn
from aisdb import DBConn, DBQuery, DomainFromPoints
import contextily as cx
import matplotlib.pyplot as plt
import random
import nest_asyncio
nest_asyncio.apply()
dbpath='YOUR_DATABASE.db' # Define the path to your database
# Set the start and end times for the query
start_time = datetime.strptime("2018-01-01 00:00:00", '%Y-%m-%d %H:%M:%S')
end_time = datetime.strptime("2018-01-03 00:00:00", '%Y-%m-%d %H:%M:%S')
# Define a circle with a 100km radius around the location point
domain = DomainFromPoints(points=[(-63.6, 44.6)], radial_distances=[100000])
def color_tracks2(tracks):
colors = {}
for track in tracks:
mmsi = track.get('mmsi')
if mmsi not in colors:
# Assign a random color to this MMSI if not already assigned
colors[mmsi] = "#{:06x}".format(random.randint(0, 0xFFFFFF))
track['color'] = colors[mmsi] # Set the color for the current track
yield track
def plot_tracks_with_contextily(tracks):
plt.figure(figsize=(12, 8))
for track in tracks:
plt.plot(track['lon'], track['lat'], color=track['color'], linewidth=2)
# Add basemap
cx.add_basemap(plt.gca(), crs='EPSG:4326', source=cx.providers.CartoDB.Positron)
plt.xlabel('Longitude')
plt.ylabel('Latitude')
plt.title('Vessel Tracks with Basemap')
plt.show()
with aisdb.SQLiteDBConn(dbpath=dbpath) as dbconn:
qry = aisdb.DBQuery(
dbconn=dbconn, start=start_time, end=end_time,
xmin=domain.boundary['xmin'], xmax=domain.boundary['xmax'],
ymin=domain.boundary['ymin'], ymax=domain.boundary['ymax'],
callback=aisdb.database.sqlfcn_callbacks.in_time_bbox_validmmsi,
)
rowgen = qry.gen_qry()
tracks = aisdb.track_gen.TrackGen(rowgen, decimate=False)
colored_tracks = list(color_tracks2(tracks))
plot_tracks_with_contextily(colored_tracks)from mpl_toolkits.basemap import Basemap
import matplotlib.pyplot as plt
def plot_tracks_with_basemap(tracks):
plt.figure(figsize=(12, 8))
# Define the geofence boundaries
llcrnrlat = 42.854329883666175 # Latitude of the southwest corner
urcrnrlat = 47.13666808816243 # Latitude of the northeast corner
llcrnrlon = -68.73998377599209 # Longitude of the southwest corner
urcrnrlon = -56.92378296577808 # Longitude of the northeast corner
# Create the Basemap object with the geofence
m = Basemap(projection='merc',
llcrnrlat=llcrnrlat, urcrnrlat=urcrnrlat,
llcrnrlon=llcrnrlon, urcrnrlon=urcrnrlon, resolution='i')
m.drawcoastlines()
m.drawcountries()
m.drawmapboundary(fill_color='aqua')
m.fillcontinents(color='lightgreen', lake_color='aqua')
for track in tracks:
lons, lats = track['lon'], track['lat']
x, y = m(lons, lats)
m.plot(x, y, color=track['color'], linewidth=2)
plt.show()
with aisdb.SQLiteDBConn(dbpath=dbpath) as dbconn:
qry = aisdb.DBQuery(
dbconn=dbconn, start=start_time, end=end_time,
xmin=domain.boundary['xmin'], xmax=domain.boundary['xmax'],
ymin=domain.boundary['ymin'], ymax=domain.boundary['ymax'],
callback=aisdb.database.sqlfcn_callbacks.in_time_bbox_validmmsi,
)
rowgen = qry.gen_qry()
tracks = aisdb.track_gen.TrackGen(rowgen, decimate=False)
colored_tracks = list(color_tracks2(tracks))
plot_tracks_with_basemap(colored_tracks)import cartopy.crs as ccrs
import matplotlib.pyplot as plt
def plot_tracks_with_cartopy(tracks):
plt.figure(figsize=(12, 8))
ax = plt.axes(projection=ccrs.Mercator())
ax.coastlines()
for track in tracks:
lons, lats = track['lon'], track['lat']
ax.plot(lons, lats, transform=ccrs.PlateCarree(), color=track['color'], linewidth=2)
plt.title('AIS Tracks Visualization with Cartopy')
plt.show()
with aisdb.SQLiteDBConn(dbpath=dbpath) as dbconn:
qry = aisdb.DBQuery(
dbconn=dbconn, start=start_time, end=end_time,
xmin=domain.boundary['xmin'], xmax=domain.boundary['xmax'],
ymin=domain.boundary['ymin'], ymax=domain.boundary['ymax'],
callback=aisdb.database.sqlfcn_callbacks.in_time_bbox_validmmsi,
)
rowgen = qry.gen_qry()
tracks = aisdb.track_gen.TrackGen(rowgen, decimate=False)
colored_tracks = list(color_tracks2(tracks))
plot_tracks_with_cartopy(colored_tracks)import plotly.graph_objects as go
import plotly.express as px
import pandas as pd
def track2dataframe(tracks):
data = []
# Iterate over each track in the vessels_generator
for track in tracks:
# Unpack static information
mmsi = track['mmsi']
rot = track['rot']
maneuver = track['maneuver']
heading = track['heading']
# Unpack dynamic information
times = track['time']
lons = track['lon']
lats = track['lat']
cogs = track['cog']
sogs = track['sog']
utc_seconds = track['utc_second']
# Iterate over the dynamic arrays and create a row for each time point
for i in range(len(times)):
data.append({
'mmsi': mmsi,
'rot': rot,
'maneuver': maneuver,
'heading': heading,
'time': times[i],
'longitude': lons[i],
'latitude': lats[i],
'cog': cogs[i],
'sog': sogs[i],
'utc_second': utc_seconds[i],
})
# Convert the list of dictionaries to a pandas DataFrame
df = pd.DataFrame(data)
return df
def plotly_visualize(data, visual_type='lines'):
if (visual_type=='scatter'):
# Create a scatter plot for the vessel data points using scatter_geo
fig = px.scatter_geo(
data,
lat="latitude",
lon="longitude",
color="mmsi", # Color by vessel identifier
hover_name="mmsi",
hover_data={"time": True},
title="Vessel Data Points"
)
else:
# Create a line plot for the vessel trajectory using scatter_geo
fig = px.line_geo(
data,
lat="latitude",
lon="longitude",
color="mmsi", # Color by vessel identifier
hover_name="mmsi",
hover_data={"time": True},
)
# Set the map style and projection
fig.update_geos(
projection_type="azimuthal equal area", # Change this to 'natural earth', 'azimuthal equal area', etc.
showland=True,
landcolor="rgb(243, 243, 243)",
countrycolor="rgb(204, 204, 204)",
lonaxis=dict(range=[-68.73998377599209, -56.92378296577808]), # Longitude range (geofence)
lataxis=dict(range=[42.854329883666175, 47.13666808816243]) # Latitude range (geofence)
)
# Set the layout to focus on a specific area or zoom level
fig.update_layout(
geo=dict(
projection_type="mercator",
center={"lat": 44.5, "lon": -63.5},
),
width=900, # Increase the width of the plot
height=700, # Increase the height of the plot
)
fig.show()
with aisdb.SQLiteDBConn(dbpath=dbpath) as dbconn:
qry = aisdb.DBQuery(
dbconn=dbconn, start=start_time, end=end_time,
xmin=domain.boundary['xmin'], xmax=domain.boundary['xmax'],
ymin=domain.boundary['ymin'], ymax=domain.boundary['ymax'],
callback=aisdb.database.sqlfcn_callbacks.in_time_bbox_validmmsi,
)
rowgen = qry.gen_qry()
tracks = aisdb.track_gen.TrackGen(rowgen, decimate=False)
df = track2dataframe(tracks)
plotly_visualize(df, 'lines')import pandas as pd
from keplergl import KeplerGl
def visualize_with_kepler(data, config=None):
map_1 = KeplerGl(height=600)
map_1.add_data(data=data, name="AIS Data")
map_1.save_to_html(file_name='./figure/kepler_map.html')
return map_1
with aisdb.SQLiteDBConn(dbpath=dbpath) as dbconn:
qry = aisdb.DBQuery(
dbconn=dbconn, start=start_time, end=end_time,
xmin=domain.boundary['xmin'], xmax=domain.boundary['xmax'],
ymin=domain.boundary['ymin'], ymax=domain.boundary['ymax'],
callback=aisdb.database.sqlfcn_callbacks.in_time_bbox_validmmsi,
)
rowgen = qry.gen_qry()
tracks = aisdb.track_gen.TrackGen(rowgen, decimate=False)
df = track2dataframe(tracks)
map_1 = visualize_with_kepler(df)# Subsea reference point (longitude, latitude, depth in meters)
x1, y1, z1 = -63.55, 44.6, 150
tracks_3d = aisdb.gis.vesseltrack_3D_dist(tracks_short, x1=x1, y1=y1, z1=z1)
for track in tracks_3d:
print(track['distance_metres'])[3680.166945539894 10636.724472053462 15827.101419957418 22932.09964031206
70614.73404949748]uv run noaa-integrator filter \
--base-dir data/extracted --dest data/filtered \
--start-year 2023 --end-year 2023 \
--min-lon -77.36 --min-lat 36.02 --max-lon -57.62 --max-lat 48.64
uv run noaa-integrator dedup --directory data/filtered/202301# SQLite
uv run noaa-integrator load --source-dir data/filtered \
--start-year 2023 --end-year 2023 --sqlite marine_cadastre.db
# PostgreSQL / TimescaleDB
export NOAA_PG_DSN='postgresql://USERNAME:PASSWORD@localhost:5432/DBNAME'
uv run noaa-integrator load --source-dir data/filtered \
--start-year 2023 --end-year 2023 --dsn --timescaledbpsql -U USERNAME -d DBNAME -h localhost -p 5432unzip ./data/AIS_2020_01_01.zip -d ./data/import pandas as pd
# Read CSV file in pandas dataframe
df_ = pd.read_csv("./data/AIS_2020_01_01.csv", parse_dates=["BaseDateTime"])
print(df_.columns)Index(['MMSI', 'BaseDateTime', 'LAT', 'LON', 'SOG', 'COG', 'Heading',
'VesselName', 'IMO', 'CallSign', 'VesselType', 'Status',
'Length', 'Width', 'Draft', 'Cargo', 'TransceiverClass'],
dtype='object')list_of_headers_ = ["MMSI","Message_ID","Repeat_indicator","Time","Millisecond","Region","Country","Base_station","Online_data","Group_code","Sequence_ID","Channel","Data_length","Vessel_Name","Call_sign","IMO","Ship_Type","Dimension_to_Bow","Dimension_to_stern","Dimension_to_port","Dimension_to_starboard","Draught","Destination","AIS_version","Navigational_status","ROT","SOG","Accuracy","Longitude","Latitude","COG","Heading","Regional","Maneuver","RAIM_flag","Communication_flag","Communication_state","UTC_year","UTC_month","UTC_day","UTC_hour","UTC_minute","UTC_second","Fixing_device","Transmission_control","ETA_month","ETA_day","ETA_hour","ETA_minute","Sequence","Destination_ID","Retransmit_flag","Country_code","Functional_ID","Data","Destination_ID_1","Sequence_1","Destination_ID_2","Sequence_2","Destination_ID_3","Sequence_3","Destination_ID_4","Sequence_4","Altitude","Altitude_sensor","Data_terminal","Mode","Safety_text","Non-standard_bits","Name_extension","Name_extension_padding","Message_ID_1_1","Offset_1_1","Message_ID_1_2","Offset_1_2","Message_ID_2_1","Offset_2_1","Destination_ID_A","Offset_A","Increment_A","Destination_ID_B","offsetB","incrementB","data_msg_type","station_ID","Z_count","num_data_words","health","unit_flag","display","DSC","band","msg22","offset1","num_slots1","timeout1","Increment_1","Offset_2","Number_slots_2","Timeout_2","Increment_2","Offset_3","Number_slots_3","Timeout_3","Increment_3","Offset_4","Number_slots_4","Timeout_4","Increment_4","ATON_type","ATON_name","off_position","ATON_status","Virtual_ATON","Channel_A","Channel_B","Tx_Rx_mode","Power","Message_indicator","Channel_A_bandwidth","Channel_B_bandwidth","Transzone_size","Longitude_1","Latitude_1","Longitude_2","Latitude_2","Station_Type","Report_Interval","Quiet_Time","Part_Number","Vendor_ID","Mother_ship_MMSI","Destination_indicator","Binary_flag","GNSS_status","spare","spare2","spare3","spare4"]# Take the first 40,000 records from the original dataframe
df = df_.iloc[0:40000]
# Create a new dataframe with the specified headers
df_new = pd.DataFrame(columns=list_of_headers_)
# Populate the new dataframe with formatted data from the original dataframe
df_new['Time'] = pd.to_datetime(df['BaseDateTime']).dt.strftime('%Y%m%d_%H%M%S')
df_new['Latitude'] = df['LAT']
df_new['Longitude'] = df['LON']
df_new['Vessel_Name'] = df['VesselName']
df_new['Call_sign'] = df['CallSign']
df_new['Ship_Type'] = df['VesselType'].fillna(0).astype(int)
df_new['Navigational_status'] = df['Status']
df_new['Draught'] = df['Draft']
df_new['Message_ID'] = 1 # Mark all messages as dynamic by default
df_new['Millisecond'] = 0
# Transfer additional columns from the original dataframe, if they exist
for col_n in df_new:
if col_n in df.columns:
df_new[col_n] = df[col_n]
# Extract static messages for each unique vessel
filtered_df = df_new[df_new['Ship_Type'].notnull() & (df_new['Ship_Type'] != 0)]
filtered_df = filtered_df.drop_duplicates(subset='MMSI', keep='first')
filtered_df = filtered_df.reset_index(drop=True)
filtered_df['Message_ID'] = 5 # Mark these as static messages
# Merge dynamic and static messages into a single dataframe
df_new = pd.concat([filtered_df, df_new])
# Save the final dataframe to a CSV file
# The quoting parameter is necessary because the csvreader reads each column value as a string by default
df_new.to_csv("./data/AIS_2020_01_01_aisdb.csv", index=False, quoting=1)import aisdb
# Establish a connection to the SQLite database and decode messages from the CSV file
with aisdb.SQLiteDBConn('./data/test_decode_msgs.db') as dbconn:
aisdb.decode_msgs(filepaths=["./data/AIS_2020_01_01_aisdb.csv"],
dbconn=dbconn, source='Testing', verbose=True)generating file checksums...
checking file dates...
creating tables and dropping table indexes...
Memory: 20.65GB remaining. CPUs: 12. Average file size: 49.12MB Spawning 4 workers
saving checksums...
processing ./data/AIS_2020_01_01_aisdb.csv
AIS_2020_01_01_aisdb.csv count: 49323 elapsed: 0.27s rate: 183129 msgs/s
cleaning temporary data...
aggregating static reports into static_202001_aggregate...sqlite3 ./data/test_decode_msgs.db
sqlite> .tables
ais_202001_dynamic coarsetype_ref static_202001_aggregate
ais_202001_static hashmap import aisdb
psql_conn_string = "postgresql://USERNAME:PASSWORD@HOST:PORT/DATABASE"
with aisdb.PostgresDBConn(libpq_connstring=psql_conn_string) as dbconn:
aisdb.decode_msgs(filepaths=["./data/AIS_2020_01_01_aisdb.csv"],
dbconn=dbconn, source='Testing', verbose=True)import os
import aisdb
with aisdb.PostgresDBConn(
host='localhost',
port=5432,
user='postgres',
password=os.environ.get('POSTGRES_PASSWORD'),
dbname='aisdb',
) as dbconn:
aisdb.decode_msgs(filepaths=["./data/AIS_2020_01_01_aisdb.csv"],
dbconn=dbconn, source='Testing', verbose=True)from datetime import datetime
import aisdb
dbpath = './data/test_decode_msgs.db'
start_time = datetime(2020, 1, 1)
end_time = datetime(2020, 1, 2)
with aisdb.SQLiteDBConn(dbpath=dbpath) as dbconn:
qry = aisdb.DBQuery(
dbconn=dbconn,
start=start_time,
end=end_time,
callback=aisdb.database.sqlfcn_callbacks.in_timerange_validmmsi,
)
rowgen = qry.gen_qry()
tracks = aisdb.TrackGen(rowgen, decimate=True)
for track in tracks:
print(track['mmsi'], track['lon'].size)import aisdb
# Drop pings recorded at 0.5 knots or slower (anchored or moored vessels)
moving_tracks = aisdb.remove_pings_wrt_speed(tracks, speed_threshold=0.5)
# Split and re-link segments where speed or distance imply impossible jumps
clean_tracks = aisdb.encode_greatcircledistance(
moving_tracks,
distance_threshold=20000, # meters
speed_threshold=50, # knots
minscore=1e-6,
)from datetime import timedelta
import aisdb
interpolated_tracks = aisdb.interp_time(clean_tracks, step=timedelta(minutes=10))import aisdb
aisdb.write_csv(interpolated_tracks, fpath='./data/processed_tracks.csv')import aisdb
if __name__ == '__main__':
aisdb.web_interface.visualize(
interpolated_tracks,
visualearth=True,
open_browser=True,
)git clone https://github.com/MAPS-Lab/NOAA-Integrator
cd NOAA-Integrator
uv sync --extra loaduv run noaa-integrator download --start-year 2023 --end-year 2023 --dest data/downloads
uv run noaa-integrator organize --base-dir data/downloads
uv run noaa-integrator extract --base-dir data/downloads --dest data/extractedAttach vessel particulars, name, IMO, ship type, flag, and tonnage, to decoded tracks from a local metadata database or an official API.
AIS position reports carry an MMSI, but static fields such as vessel name, IMO number, ship type, flag, and tonnage are broadcast far less often and are frequently missing from decoded data. This tutorial covers how AISdb attaches vessel metadata from a local metadata database to query results, and where that metadata can come from today.
AISdb caches vessel metadata in a standalone SQLite database, separate from your AIS track database, so it can be joined back onto tracks from any query. aisdb.webdata.marinetraffic.VesselInfo opens (or creates) that database and exposes it through its .trafficDB attribute. The table layout is one row per MMSI with name, callsign, flag, gross tonnage, summer deadweight, length and breadth, year built, and home port.
If you have a metadata database built with an earlier AISdb release, it remains fully usable. Databases populated from an external source (for example, an API subscription) work as well, as long as they follow the same webdata_marinetraffic table schema.
To read cached metadata and attach it to tracks generated from TrackGen, use aisdb.webdata.marinetraffic.vessel_info. It takes the track generator and the raw SQLite connection to the traffic database, and merges each track dictionary with the cached row for its MMSI, or a null-filled record if no metadata was found:
Each track dictionary gains a marinetraffic_info key holding the cached row (or the null placeholder if that MMSI was never resolved), and 'marinetraffic_info' is added to the track's static field set so downstream code knows to treat it as vessel-level rather than per-position data.
MarineTraffic sells a supported REST API with paid subscription tiers. It is not part of AISdb, but it is the supported way to obtain vessel particulars in bulk now that scraping is off the table. The snippet below is illustrative; the exact service name, response fields, and pricing tier depend on your MarineTraffic subscription, so check MarineTraffic's own API documentation for the endpoint and parameters your account has access to.
Rows fetched this way can be inserted into the VesselInfo database so vessel_info picks them up on the next join, giving you the same track-merging workflow with a supported data source behind it.
Export decoded AIS tracks from a SQLite or PostgreSQL AISdb database to CSV, using the built-in write_csv() writer or a custom column selection.
Building on the Database Loading tutorial, where we used AIS data to create AISdb databases, you can export that data back out to CSV for use in spreadsheets, R, pandas, or any tool that doesn't speak SQLite or PostgreSQL directly. AISdb ships a built-in writer for this, aisdb.write_csv(), which takes a track generator and handles column ordering, static-versus-dynamic fields, and value sanitization for you. This section covers that built-in path first, then shows how to write a custom CSV yourself when you only want a handful of columns.
Connect to the database, run a query, and hand the resulting tracks to aisdb.write_csv(). The whole flow, query and write, needs to happen inside the same with block, because TrackGen and DBQuery.gen_qry() are lazy generators that read from the database connection as they're consumed. Once the with block exits and the connection closes, iterating over tracks later will fail. The pattern is identical for SQLite and PostgreSQL, only the connection object changes; PostgresDBConn accepts the same keyword arguments as psycopg, including host, port, user, dbname, and password, or a single libpq_connstring.
Checking the output file:
Checking the output file:
write_csv() accepts the track generator, an output path (or an io.BytesIO / SpooledTemporaryFile buffer if you'd rather write in memory), and an optional skipcols list of column names to leave out, which defaults to ['label', 'in_zone']. Internally it calls aisdb.proc_util.tracks_csv(), which figures out the column order from the static and dynamic fields present on the first track, appends a datetime column derived from the epoch time values, and rounds a handful of numeric fields (lon, lat, and, when present, distance and depth columns from other tutorials) to a fixed number of decimals. Each AIS message gets its own row in the CSV, with one value per dynamic field, tagged with a
mmsi and maneuver are static per-track values (each vessel keeps one MMSI and reports its maneuver indicator once per track vector), while lon, lat, cog, sog, heading, rot, and utc_second are dynamic and change with every reported position.
write_csv() writes every column it finds, which is usually what you want. If you'd rather control the exact set of columns yourself, iterate the tracks manually and write rows with the standard library csv module. Two things trip people up here, and both come from the static-versus-dynamic distinction in a track dictionary. First, mmsi and maneuver are static, so they're scalar values on the track and shouldn't be indexed. Second, lon, lat, cog, sog, heading, rot, and utc_second
If you're building rows yourself outside of csv.DictWriter, AISdb also exposes the two lower-level pieces write_csv() is built from. aisdb.proc_util.tracks_csv(tracks, skipcols=['label', 'in_zone']) is a generator that yields the header row followed by one row per AIS message, already column-ordered and sanitized, which you can feed to your own writer. aisdb.proc_util.write_csv_rows(rows, pathname='output.csv', mode='a') takes any iterable of row tuples and appends them to a file, useful when you're assembling rows from several sources before writing. Neither function is re-exported at the top level, so import them from aisdb.proc_util directly.
Extracting distance features from and to points-of-interest using raster files.
The distance of a vessel from the nearest shore, coast, and port is a useful feature for vessel behavior analysis, environmental monitoring, and maritime safety assessments. AISdb offers functions to acquire these distances for specific vessel positions using raster files sourced from NASA and Global Fishing Watch. In this tutorial, we calculate the distance in kilometers from shore, coast, and the nearest port for a sample track, then show how to fetch a list of real-world ports for a bounding box.
First, we create a sample track:
import numpy as np
import aisdb
from datetime import datetime
from aisdb.gis import dt_2_epoch
y1, x1 = 44.57039426840729, -63.52931373766157
y2, x2 = 44.51304767533133, -63.494075674952555
y3, x3 = 44.458038982492134, -63.535634138077945
y4, x4 = 44.393941339104074, -63.53826396955358
y5, x5 = 44.14245580737021, -64.16608964280064
t1 = dt_2_epoch( datetime(2021, 1, 1, 1) )
t2 = dt_2_epoch( datetime(2021, 1, 1, 2) )
t3 = dt_2_epoch( datetime(2021, 1, 1, 3) )
t4 = dt_2_epoch( datetime(2021, 1, 1, 4) )
t5 = dt_2_epoch( datetime(2021, 1, 1, 7) )
# creating a sample track
tracks_short = [
dict(
mmsi=123456789,
lon=np.array([x1, x2, x3, x4, x5]),
lat=np.array([y1, y2, y3, y4, y5]),
time=np.array([t1, t2, t3, t4, t5]),
dynamic=set(['lon', 'lat', 'time']),
static=set(['mmsi'])
)
]Here is what the sample track looks like:
ShoreDist, CoastDist, and PortDist all follow the same pattern, downloading the relevant raster from the AISdb data release on GitHub, merging it against the provided track list, and storing the result under a new dynamic key.
The class is used to calculate the nearest distance to shore, using a raster file containing shore distance data. Currently, calling the get_distance function in ShoreDist will automatically download the shore distance raster file from the AISdb data release on GitHub. The function then merges the tracks in the provided track list, creates a new key, "km_from_shore", and stores the shore distance as the value for this key.
Similar to acquiring the distance from shore, CoastDist is implemented to obtain the distance between the given track positions and the coastline.
Like the distances from the coast and shore, the class determines the distance between the track positions and the nearest ports.
ShoreDist, CoastDist, and PortDist all answer the same question from the vessel's side, how far is this point from a raster surface. Sometimes you need the opposite view, a list of the actual ports inside a region so you can label tracks, filter for cargo-capable harbors, or join port metadata onto your AIS data. AISdb exposes this through aisdb.ports.api.WorldPortIndexClient, which queries the World Port Index feature service maintained by the National Geospatial-Intelligence Agency and returns the result as a pandas DataFrame.
fetch_ports takes a bounding box as lat_min, lat_max, lon_min, lon_max, and returns every port whose coordinates fall inside it. Passing save=True along with out_path writes the result to a CSV file as well. The example below queries the Gulf of St. Lawrence, then narrows the result to ports with a usable cargo depth using filter_by_cargo_depth.
Each row in df_ports carries the port's LAT and LON columns along with the full set of World Port Index fields, such as PORT_NAME and HARBORSIZE. From here it is straightforward to combine the port list with the tutorial, indexing each port with aisdb.discretize.h3.Discretizer so it shares the same spatial grid as your decoded AIS tracks, or joining km_from_port values back to the nearest named port for reporting.

import aisdb
from datetime import datetime
from aisdb.webdata.marinetraffic import vessel_info, VesselInfo
dbpath = './test_database.db'
start_time = datetime(2021, 7, 1)
end_time = datetime(2021, 7, 2)
with aisdb.SQLiteDBConn(dbpath=dbpath) as dbconn:
qry = aisdb.DBQuery(
dbconn=dbconn,
callback=aisdb.database.sqlfcn_callbacks.in_timerange_validmmsi,
start=start_time,
end=end_time,
)
rowgen = qry.gen_qry()
tracks = aisdb.track_gen.TrackGen(rowgen, decimate=False)
vinfoDB = VesselInfo('./testdata/traffic_info.db').trafficDB
with vinfoDB as trafficDB:
for track in vessel_info(tracks, trafficDB):
print(track['mmsi'], track['marinetraffic_info'])import requests
# Your MarineTraffic API key (from a paid subscription)
api_key = 'your_marine_traffic_api_key'
# MMSI numbers to query
mmsi_list = [228386800, 366773000]
# Illustrative endpoint, confirm the exact path and parameters
# against your MarineTraffic subscription's API documentation
url = f'https://services.marinetraffic.com/api/exportvessels/{api_key}'
params = {
'shipid': ','.join(str(m) for m in mmsi_list),
'protocol': 'jsono',
'msgtype': 'extended',
}
response = requests.get(url, params=params)
response.raise_for_status()
for vessel in response.json():
print(f"Vessel Name: {vessel.get('NAME')}")
print(f"MMSI: {vessel.get('MMSI')}")
print(f"IMO: {vessel.get('IMO')}")
print(f"Flag: {vessel.get('COUNTRY')}")from aisdb.webdata.shore_dist import ShoreDist
with ShoreDist(data_dir="./testdata/") as sdist:
# Getting distance from shore for each point in the track
for track in sdist.get_distance(tracks_short):
assert 'km_from_shore' in track['dynamic']
assert 'km_from_shore' in track.keys()
print(track['km_from_shore'])[ 1 3 2 9 14]from aisdb.webdata.shore_dist import CoastDist
with CoastDist(data_dir="./testdata/") as cdist:
# Getting distance from the coast for each point in the track
for track in cdist.get_distance(tracks_short):
assert 'km_from_coast' in track['dynamic']
assert 'km_from_coast' in track.keys()
print(track['km_from_coast'])[ 1 3 2 8 13]from aisdb.webdata.shore_dist import PortDist
with PortDist(data_dir="./testdata/") as pdist:
# Getting distance from the port for each point in the track
for track in pdist.get_distance(tracks_short):
assert 'km_from_port' in track['dynamic']
assert 'km_from_port' in track.keys()
print(track['km_from_port'])[ 4.72144175 7.47747231 4.60478449 11.5642271 28.62511253]from aisdb.ports.api import WorldPortIndexClient
client = WorldPortIndexClient()
# Query the Gulf of St. Lawrence region
df_ports = client.fetch_ports(
lat_min=45.0,
lat_max=51.5,
lon_min=-71.5,
lon_max=-55.0
)
# Filter for cargo-capable ports
df_cargo = client.filter_by_cargo_depth(df_ports)Track_IDmmsi,datetime,time,lon,lat,cog,sog,heading,rot,utc_second,maneuver,Track_ID
219014000,2018-01-01 00:44:44,1514767484,-63.53717,44.63583,322,0.0,295.0,0.0,44,0,1
219014000,2018-01-01 13:44:44,1514814284,-63.53717,44.63583,119,0.0,295.0,0.0,45,0,1
219014000,2018-01-01 17:43:03,1514829783,-63.53717,44.63583,143,0.0,295.0,0.0,15,0,1import aisdb
from aisdb import DBQuery, DomainFromPoints
from aisdb.database.dbconn import PostgresDBConn
from datetime import datetime
domain = DomainFromPoints(points=[(-63.6, 44.6)], radial_distances=[50000])
with PostgresDBConn(
host='localhost', # PostgreSQL address
port=5432, # PostgreSQL port
user='your_username', # PostgreSQL username
password='your_password', # PostgreSQL password
dbname='database_name', # database name
) as dbconn:
qry = DBQuery(
dbconn=dbconn,
start=datetime(2023, 1, 1), end=datetime(2023, 1, 3),
xmin=domain.boundary['xmin'], xmax=domain.boundary['xmax'],
ymin=domain.boundary['ymin'], ymax=domain.boundary['ymax'],
callback=aisdb.database.sqlfcn_callbacks.in_time_bbox_validmmsi,
)
tracks = aisdb.track_gen.TrackGen(qry.gen_qry(), decimate=False)
aisdb.write_csv(tracks, 'output_postgresql.csv')
print("All tracks have been written to output_postgresql.csv")mmsi,datetime,time,lon,lat,cog,sog,heading,rot,utc_second,maneuver,Track_ID
210108000,2023-01-01 00:41:51,1672545711,-63.64500,44.68833,173,0.0,0.0,0.0,0,0,1
210108000,2023-01-01 00:44:52,1672545892,-63.64500,44.68833,208,0.0,0.0,0.0,0,0,1
210108000,2023-01-01 00:47:51,1672546071,-63.64500,44.68833,176,0.0,0.0,0.0,0,0,1import csv
import aisdb
from aisdb import SQLiteDBConn, DBQuery, DomainFromPoints
from datetime import datetime
dbpath = 'YOUR_DATABASE.db'
start_time = datetime.strptime("2018-01-01 00:00:00", '%Y-%m-%d %H:%M:%S')
end_time = datetime.strptime("2018-01-02 00:00:00", '%Y-%m-%d %H:%M:%S')
domain = DomainFromPoints(points=[(-63.6, 44.6)], radial_distances=[50000])
headers = ['mmsi', 'time', 'lon', 'lat', 'cog', 'sog',
'utc_second', 'heading', 'rot', 'maneuver']
with SQLiteDBConn(dbpath=dbpath) as dbconn, open('custom_output.csv', mode='w', newline='') as file:
qry = DBQuery(
dbconn=dbconn, start=start_time, end=end_time,
xmin=domain.boundary['xmin'], xmax=domain.boundary['xmax'],
ymin=domain.boundary['ymin'], ymax=domain.boundary['ymax'],
callback=aisdb.database.sqlfcn_callbacks.in_time_bbox_validmmsi,
)
tracks = aisdb.track_gen.TrackGen(qry.gen_qry(), decimate=False)
writer = csv.DictWriter(file, fieldnames=headers)
writer.writeheader()
for track in tracks:
for i in range(len(track['time'])):
writer.writerow({
'mmsi': track['mmsi'],
'time': track['time'][i],
'lon': track['lon'][i],
'lat': track['lat'][i],
'cog': track['cog'][i],
'sog': track['sog'][i],
'utc_second': track['utc_second'][i],
'heading': track['heading'][i],
'rot': track['rot'][i],
'maneuver': track['maneuver'],
})
print("All tracks have been written to custom_output.csv")import aisdb
from aisdb import SQLiteDBConn, DBQuery, DomainFromPoints
from datetime import datetime
dbpath = 'YOUR_DATABASE.db' # path to your database
start_time = datetime.strptime("2018-01-01 00:00:00", '%Y-%m-%d %H:%M:%S')
end_time = datetime.strptime("2018-01-02 00:00:00", '%Y-%m-%d %H:%M:%S')
# a 50 km radius around a point off Halifax harbor
domain = DomainFromPoints(points=[(-63.6, 44.6)], radial_distances=[50000])
with SQLiteDBConn(dbpath=dbpath) as dbconn:
qry = DBQuery(
dbconn=dbconn, start=start_time, end=end_time,
xmin=domain.boundary['xmin'], xmax=domain.boundary['
ymin=domain.boundary['ymin'], ymax=domain.boundary['
callback=aisdb.database.sqlfcn_callbacks.in_time_bbox_validmmsi,
)
tracks = aisdb.track_gen.TrackGen(qry.gen_qry(), decimate=False)
aisdb.write_csv(tracks, 'output_sqlite.csv')
print("All tracks have been written to output_sqlite.csv")Reduce AIS track density with AISdb's Visvalingam-Whyatt line simplification, through TrackGen's decimate argument or direct calls.
Automatic Identification System (AIS) data provides a wealth of insight into maritime activity, including vessel movements and traffic patterns. However, the raw volume, often millions or even billions of GPS position reports, can be overwhelming. Processing and visualizing this data directly is computationally expensive, slow, and hard to interpret.
This is where AISdb's decimation comes in. It helps you reduce data clutter so you can focus on the positions that actually matter to your analysis.
Decimation, in simple terms, means reducing the number of data points. When applied to AIS tracks, it selectively removes GPS points from a vessel's trajectory while preserving its overall shape and key characteristics. Rather than processing every recorded position, decimation algorithms identify and retain the most relevant points, optimizing data efficiency without significant loss of accuracy.
Think of it like simplifying a drawing. Instead of using thousands of tiny dots to represent a complex image, you can use fewer, strategically chosen points to capture its essence. Decimation does the same for a vessel's path, trimming the point count while keeping the core trajectory intact, which makes downstream analysis and visualization more efficient.
There are several key benefits to using decimation when working with AIS data.
Improved Performance and Efficiency. Reducing the number of data points dramatically decreases the computational load, enabling faster analyses, quicker visualizations, and a more effective workflow, especially when dealing with large datasets.
Clearer Visualizations. Dense tracks clutter visualizations and make the data hard to interpret. Decimation simplifies the tracks, emphasizing significant movements and patterns for more intuitive analysis.
Noise Reduction. Decimation is not designed as a noise removal technique, but it can help smooth out minor inaccuracies and high-frequency fluctuations in raw GPS data, which is useful for focusing on broader trends and vessel movements.
In AISdb, decimation is exposed through the decimate argument of aisdb.track_gen.TrackGen(). TrackGen() has no default for this argument, so every call must pass decimate explicitly, either True, False, or a specific precision value.
decimate=False skips simplification entirely. Every position returned by your query is kept.
decimate=True applies the Rust-native simplify_linestring_idx(x, y, precision) function with a precision of 0.0001.
decimate=<float>
simplify_linestring_idx() is implemented in AISdb's Rust extension (built with PyO3 and exposed via aisdb.track_gen.simplify_linestring_idx) and uses the to simplify vessel tracks while preserving key trajectory details.
One detail is worth knowing when you inspect the output. TrackGen() always segments a track wherever consecutive longitude values jump by more than 300 degrees, independent of the decimate setting. That split handles vessels crossing the antimeridian, where longitude wraps from close to +180 to close to -180, and prevents a single track from being drawn as a straight line across the entire globe.
The Visvalingam-Whyatt algorithm is a line simplification method. It works by removing points that contribute the least to the overall shape of the line. Here is the basic idea.
The algorithm measures the importance of a point by calculating the area of the triangle formed by that point and its adjacent points.
Points on relatively straight segments form smaller triangles, meaning they're less important in defining the shape.
Points at curves and corners form larger triangles, signaling that they're crucial for maintaining the line's characteristic form.
The algorithm iteratively removes the points with the smallest triangle areas until the desired level of simplification is reached, controlled by the precision value. In AISdb, this process is triggered through the decimate parameter of TrackGen(), or by calling simplify_linestring_idx() directly if you want to decide precision on a track-by-track basis.
Below is a working example that queries a bounding box and time range from a SQLite database, then generates decimated tracks:
The exact MMSIs and point counts depend on which vessels fall inside your bounding box and time range, so treat the numbers above as illustrative rather than something to match.
TrackGen() is a generator, so nothing is computed until you iterate over it. Each yielded track is a dictionary of NumPy arrays (lon, lat, time, sog, cog, and more) plus scalar static fields such as mmsi.
If you need finer control, for example applying different precision values to different tracks, call simplify_linestring_idx() yourself after generating undecimated tracks:
simplify_linestring_idx(x, y, precision) takes the longitude and latitude arrays and returns the indices of the points to keep. Indexing every array in track['dynamic'] with that result, rather than just lon/lat/time, keeps sog, cog, and the other dynamic fields aligned with the simplified positions.
Precision. Controls the level of simplification passed to simplify_linestring_idx(). A smaller value (e.g., 0.0001, the default used when decimate=True) keeps more points and higher fidelity, while a larger value (e.g., 0.01) simplifies the track further, keeping fewer points.
x, y. The lon and lat NumPy arrays produced by
Decimation is a powerful tool for simplifying and decluttering AIS data. With the decimate argument of TrackGen() and, when you need more control, direct calls to simplify_linestring_idx(), AISdb lets you process data more efficiently, build clearer visualizations, and get to insights faster. Experiment with different precision values and see how much detail you can strip away before it starts to matter for your analysis.
Amigo D, Sánchez Pedroche D, García J, Molina JM. Review and classification of trajectory summarisation algorithms: From compression to segmentation. International Journal of Distributed Sensor Networks. 2021;17(10). doi:
An overview of the Automatic Identification System (AIS), the ITU-R M.1371 message set, transponder classes, MMSI structure, and the limitations of AIS as a maritime surveillance data source.
The Automatic Identification System (AIS) is a standardized, unencrypted self-reporting maritime surveillance system. It was developed for collision avoidance and vessel traffic management, and it has become the backbone of modern maritime domain awareness because the same broadcasts that keep ships apart at sea are also picked up by shore stations and satellites and archived for research, safety, and enforcement.
Every AIS-equipped vessel carries a transponder that broadcasts its identity, position, and movement over VHF radio on two dedicated marine channels (AIS 1 at 161.975 MHz and AIS 2 at 162.025 MHz). Transponders share these channels using a self-organizing time division multiple access scheme, so each unit knows in advance which time slot it may transmit in and does not need a central coordinator.
Reporting rate depends on the vessel's navigational status and speed. An anchored or moored vessel transmits roughly once every three minutes, while a vessel underway and maneuvering can transmit every two seconds. This adaptive rate keeps the channel usable in busy waters while still giving high-speed or maneuvering traffic a near real-time track.
Because AIS is broadcast in the clear, any station within VHF range can receive it. Coastal base stations relay local traffic to shore-based networks, and a growing constellation of AIS-equipped satellites captures transmissions far offshore, well beyond the roughly 40 nautical mile horizon of terrestrial VHF reception.
Types 1, 2, and 3, Position Report Class A. The core dynamic report from a Class A transponder. Carries latitude, longitude, speed over ground (SOG), course over ground (COG), true heading, rate of turn (ROT), and navigational status (underway, at anchor, moored, and so on).
Type 4, Base Station Report. Sent by shore stations to broadcast their position and UTC time, which vessels use to keep their onboard clocks synchronized.
Type 5, Static and Voyage Related Data. The Class A "identity card" message. Carries the MMSI, IMO number, call sign, vessel name, ship type, dimensions, destination, and estimated time of arrival.
Type 9, Standard SAR Aircraft Position Report. Used by search-and-rescue aircraft rather than surface vessels.
Types 18 and 19, Class B Position Report (standard and extended). The Class B equivalent of types 1 to 3, with a simpler set of fields and a lower reporting rate.
Type 21, Aid-to-Navigation Report. Broadcast by AIS-equipped buoys, lighthouses, and other fixed navigational aids rather than vessels.
Type 24, Static Data Report. The Class B equivalent of type 5, split across two parts (A and B) because Class B messages are shorter than Class A messages.
Type 27, Long Range AIS Broadcast Message. A compressed position report intended for satellite reception, used when a vessel is far from any base station.
The remaining types cover binary and safety-related messaging (types 6, 7, 8, 12, 13, 14), channel and slot management between base stations and transponders (types 15, 16, 17, 20, 22, 23), and other niche functions. AISdb's decoders parse the position and voyage-relevant subset of this message set out of raw NMEA sentences, since those are the messages that carry ship movement and identity data.
Not every vessel carries the same equipment, and the class of transponder determines both the reporting rate and the level of detail in the static data.
Class A transponders are mandatory under the SOLAS convention for vessels of 300 gross tonnage or more on international voyages, cargo vessels of 500 gross tonnage or more on domestic voyages, and all passenger ships regardless of size. They transmit at up to 12.5 watts, report position as often as every two seconds when maneuvering, and carry the full static and voyage data set (type 5), including destination and ETA.
Class B transponders are a lower-cost, lower-power option (typically 2 watts) aimed at fishing boats, pleasure craft, and other vessels not required to carry Class A equipment. They report less frequently, generally every 5 to 30 seconds depending on speed, and their static data (type 24) omits fields like destination and ETA. Class B units also use a different channel access method (carrier-sense TDMA rather than the self-organized TDMA that Class A uses), which means Class B position reports can be deprioritized on a congested channel.
This distinction matters for anyone analyzing AIS data. A dataset built only from Class A traffic will systematically under-represent small craft, and reporting-interval gaps that look like anomalies in a Class B track may simply be normal behavior for that equipment class.
The Maritime Mobile Service Identity (MMSI) is the nine-digit number that uniquely identifies a station on the AIS network, and its digit pattern encodes what kind of station it is.
Ships use a plain nine-digit MMSI, where the first three digits are the Maritime Identification Digits (MID), a code assigned by the ITU to the vessel's flag state. For example, MMSIs beginning with 366 through 369 are registered to the United States, and 235 or 232 to the United Kingdom.
Coastal stations use the format 00MIDXXXXX, with two leading zeros followed by the MID.
Group calls to a coastal station use 0MIDXXXXXX, with a single leading zero.
Aids to navigation (buoys, lighthouses) use 99MIDXXXXX, with two leading nines.
Craft associated with a parent vessel, such as a ship's tender, use 98MIDXXXXX.
SAR aircraft use 111MIDXXX.
AIS search and rescue transmitters (AIS-SART, man overboard devices, EPIRBs) use MMSIs beginning with 970 through 974.
Because the MID identifies flag state rather than a specific registry authority, MMSIs are not guaranteed to be globally unique in practice. Duplicate, reassigned, or malformed MMSIs are common enough in real-world data that treating MMSI as a perfectly reliable vessel key is a frequent source of downstream error in AIS analysis.
AIS traffic splits into two broad categories that behave very differently in a database.
Dynamic messages convey a vessel's real-time state, and their values change from one transmission to the next. This includes speed over ground (SOG), course over ground (COG), rate of turn (ROT), navigational status, and the vessel's current latitude and longitude. These are the high-frequency messages (types 1, 2, 3, 18, 19, and 27) that build up a vessel's track over time.
Static messages carry information that stays fixed for the duration of a voyage or longer, such as the Maritime Mobile Service Identity (MMSI), International Maritime Organization (IMO) number, vessel name, call sign, ship type, dimensions, and destination. These arrive far less often (types 5 and 24) since there is no need to retransmit information that has not changed.
This split shapes how AIS data is typically stored and queried. Dynamic reports accumulate into a large, append-only position history, while static reports are better treated as a slowly changing lookup table keyed on MMSI. Joining the two lets you attach vessel identity and characteristics to a raw stream of positions.
AIS is not a complete or tamper-proof picture of maritime traffic, and any analysis built on it should account for a few structural gaps.
Loss of signal. A vessel can simply turn off its transponder, a practice sometimes called "going dark," to avoid detection during illegal fishing, sanctions evasion, or other activity it does not want tracked. Equipment failure and battery loss on smaller craft produce the same effect unintentionally.
Reception range. Terrestrial base stations are limited to VHF line-of-sight range, roughly 40 nautical miles depending on antenna height, so open-ocean traffic depends on satellite AIS receivers, and even those have gaps driven by orbital coverage and message collisions in high-traffic areas.
Message integrity. Because AIS is unencrypted and unauthenticated, positions and identities can be spoofed or manipulated, which means any pipeline consuming raw AIS should treat wildly implausible speeds, positions, or MMSIs as suspect rather than as ground truth.
Uneven adoption. Not every vessel on the water carries a transponder, and Class B equipment reports less often than Class A, so smaller and non-commercial vessels are consistently under-represented relative to their actual numbers.
To learn more about AIS, refer to Marinelink's definitive guide to AIS or the ITU-R M.1371 recommendation itself.
Brousseau, M. (2022). A comprehensive analysis and novel methods for on-purpose AIS switch-off detection [Master's thesis, Dalhousie University]. DalSpace. http://hdl.handle.net/10222/81160
Kazim, T. (2016, November 14). A definitive guide to AIS. MarineLink. Retrieved May 14, 2025, from https://www.marinelink.com/news/definitive-guide-ais418266
International Telecommunication Union. (2014). Technical characteristics for an automatic identification system using time division multiple access in the VHF maritime mobile frequency band (Recommendation ITU-R M.1371-5).

0.0001TrackGen()TrackGen integration. Passing decimate=True (or a float) to aisdb.track_gen.TrackGen() applies simplify_linestring_idx() automatically to each track as it is generated. Calling simplify_linestring_idx() yourself, after decimate=False, is only necessary when you want per-track control over precision.
Iterative refinement. Decimation is often an iterative process. Visualize the decimated tracks, assess the level of simplification, and adjust precision to balance simplification against data fidelity.
import aisdb
from datetime import datetime
dbpath = 'your_ais_database.db'
with aisdb.SQLiteDBConn(dbpath=dbpath) as dbconn:
qry = aisdb.DBQuery(
dbconn=dbconn,
start=datetime(2023, 1, 1), end=datetime(2023, 1, 2), # time range
xmin=-10, xmax=0, ymin=40, ymax=50, # bounding box
callback=aisdb.database.sqlfcn_callbacks.in_validmmsi_bbox,
)
rowgen = qry.gen_qry()
decimated_tracks = aisdb.track_gen.TrackGen(rowgen, decimate=True)
for track in decimated_tracks:
print(f"MMSI: {track['mmsi']}, points: {track['lon'].size}")MMSI: 316001234, points: 41
MMSI: 316005678, points: 118
MMSI: 366912340, points: 27import aisdb
from datetime import datetime
dbpath = 'your_ais_database.db'
with aisdb.SQLiteDBConn(dbpath=dbpath) as dbconn:
qry = aisdb.DBQuery(
dbconn=dbconn,
start=datetime(2023, 1, 1), end=datetime(2023, 1, 2),
xmin=-10, xmax=0, ymin=40, ymax=50,
callback=aisdb.database.sqlfcn_callbacks.in_validmmsi_bbox,
)
rowgen = qry.gen_qry()
tracks = aisdb.track_gen.TrackGen(rowgen, decimate=False) # keep every point
simplified_tracks = []
for track in tracks:
if track['lon'].size > 2: # simplification needs at least 3 points
idx = aisdb.track_gen.simplify_linestring_idx(
track['lon'], track['lat'], precision=0.01
)
simplified_track = dict(
**{k: track[k] for k in track['static']},
**{k: track[k][idx] for k in track['dynamic']},
static=track['static'],
dynamic=track['dynamic'],
)
simplified_tracks.append(simplified_track)
else:
simplified_tracks.append(track) # too short to simplify further
for track in simplified_tracks:
print(f"MMSI: {track['mmsi']}, points: {track['lon'].size}")Merge GEBCO global bathymetric grids onto AIS tracks with AISdb, then color and visualize vessel movement by the depth of water it traveled through.
Vessel behavior often depends on what's beneath the hull as much as what's around it. A ship hugging the 20-meter contour is behaving very differently from one crossing open ocean, and that context is invisible in AIS data alone. AISdb closes that gap with aisdb.webdata.bathymetry.Gebco, a class that fetches GEBCO global bathymetric grids and merges depth values directly onto your track data.
In the example below, we find every vessel within a 500-kilometer radius of Halifax, Canada, on January 1, 2018, then color each track by the depth of water it traveled through.
Gebco wraps GEBCO's raster tiles behind a single interface. Give it a data_dir and it takes care of the rest, checking whether the tiles are already on disk and downloading them if not.
import os
import aisdb
from datetime import datetime
from aisdb import SQLiteDBConn, DBQuery, DomainFromPoints
from aisdb.webdata.bathymetry import Gebco
# set the path to the data storage directory
bathymetry_data_dir = "./bathymetry_data/"
os.makedirs(bathymetry_data_dir, exist_ok=True)
# opening Gebco as a context manager triggers the download if the
# raster tiles aren't already present in data_dir
with Gebco(data_dir=bathymetry_data_dir) as bathy:
print("Bathymetry rasters ready:", list(bathy.rasterfiles.keys()))Bathymetry rasters ready: ['gebco_2022_n0.0_s-90.0_w0.0_e90.0.tif', 'gebco_2022_n90.0_s0.0_w-90.0_e0.0.tif']Each key names the quadrant it covers, gebco_2022_n<north>_s<south>_w<west>_e<east>.tif, so the list above tells you exactly which 90-degree tiles landed on disk. Halifax sits in the northwestern quadrant, so only the second tile actually gets opened once you start merging tracks.
Be prepared for what that download involves. GEBCO's global bathymetry, split into regional GeoTIFF tiles by latitude and longitude, ships as two 7-Zip archives attached to the AISdb data release on GitHub, and together they exceed two gigabytes. The first time you point Gebco at an empty data_dir, expect a multi-minute download over a decent connection, followed by extraction with the system 7z binary if it's installed, or with py7zr as a pure-Python fallback if it isn't. Once extraction finishes, the archives themselves are deleted and only the .tif tiles remain. After that first run, Gebco sees the tiles already on disk and skips the download entirely, so budget the wait once per machine, not once per script run.
With the bathymetry grid staged, the next step is a normal AISdb query. We define a 500-kilometer domain around Halifax and pull every position report from that window.
Gebco.merge_tracks takes the track generator and, for every position, looks up which raster tile covers that coordinate, loading it on demand, then reads the depth value at that point. It appends a depth_metres column to each track and adds it to the track's dynamic field set, so it travels along with lon, lat, and time.
Because a single track can cross more than one raster tile, Gebco keeps every tile it has touched open for the lifetime of the with
Depth values from Gebco are returned in meters below sea level. We use that to bucket each track into a rough depth class and set a color field, which aisdb.web_interface.visualize reads directly.
These bands loosely follow real ocean depth zones. The shelf around Nova Scotia rarely exceeds 200 meters, the slope drops off quickly beyond it, and abyssal depths in the North Atlantic run a few thousand meters deep before the rare trench pushes past 6,000.
visualize starts a local web server and serves the colored tracks over a websocket, opening your default browser to view the result. Passing domain=domain draws the search boundary on the map alongside the tracks.
The integrated results are color-coded and can be visualized as shown below.
Yellow marks tracks with an average depth of 200 meters or less (continental shelf).
In this page, we will see how we can use AISdb to discretize AIS tracks to hexagons.
H3 is Uber's hierarchical hexagonal geospatial indexing system. It partitions the Earth into a multi-resolution hexagonal grid, and its key advantage over square grids is the "one-distance rule," where all neighbors of a hexagon lie at comparable step distances.
As illustrated in the figure above, this uniformity removes the diagonal-versus-edge ambiguity present in square lattices. For maritime work, hexagons are great because they reduce directional bias and make neighborhood queries and aggregation intuitive.
H3 indexes are 64-bit IDs typically shown as hex strings, such as
860e4d31fffffff.
AISdb's aisdb.discretize.h3.Discretizer class wraps the bindings so you can go from a stream of AIS tracks straight to H3 cell IDs, without hand-rolling the lat/lon-to-cell conversion yourself. The code below connects to a PostgreSQL database, queries a bounding box and time window in the Gulf of St. Lawrence, and tags each point in the resulting tracks with its H3 index.
TrackGenOnce merge_tracks finishes, depth_metres holds one value per position report, aligned with lon, lat, and time. As the coloring section below explains, GEBCO reports depth as a positive number below sea level, so a track's array climbs from near zero at the shelf edge into the thousands over open ocean. A position that lands on a land pixel in the raster (a harbor entrance hugging the coast, for instance) can come back negative, since that's elevation above sea level rather than depth. Printing one track's array after the merge shows the shape to expect, with exact values depending on the vessel's actual route and the resolution of the underlying grid.
Orange marks tracks with an average depth between 200 and 2,000 meters (continental slope).
Pink marks tracks with an average depth between 2,000 and 6,000 meters (abyssal plain).
Red marks tracks with an average depth greater than 6,000 meters (deep trenches).

Discretizer also exposes a couple of methods worth knowing about beyond the streaming path above. get_h3_index(lat, lon) converts a single coordinate pair to its H3 cell ID, useful when you already have a point and don't need the full track pipeline. get_polygon_from_cells(cells, tight=True) takes a list of H3 cell IDs and returns their combined boundary as a Shapely geometry, handy for drawing the hexagon footprint of a set of cells on a map. And describe() prints and plots how hexagon area changes with latitude at the chosen resolution, plus a table of edge lengths across all 16 H3 resolutions, which is a quick way to sanity-check that the resolution you picked matches the scale of the analysis.describe() relies on matplotlib and geopandas, both of which install automatically as core AISdb dependencies, so there's nothing extra to add.
Refer to the example notebook for the full walkthrough, including the describe() output. https://github.com/MAPS-Lab/AISdb/blob/master/examples/discretize.ipynb

dbpath = "YOUR_DATABASE.db" # path to your AISdb database
start_time = datetime.strptime("2018-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
end_time = datetime.strptime("2018-01-02 00:00:00", "%Y-%m-%d %H:%M:%S")
domain = DomainFromPoints(points=[(-63.6, 44.6)], radial_distances=[500000])
with SQLiteDBConn(dbpath=dbpath) as dbconn:
qry = DBQuery(
dbconn=dbconn, start=start_time, end=end_time,
xmin=domain.boundary["xmin"], xmax=domain.boundary["xmax"],
ymin=domain.boundary["ymin"], ymax=domain.boundary["ymax"],
callback=aisdb.database.sqlfcn_callbacks.in_time_bbox_validmmsi,
)
tracks = aisdb.track_gen.TrackGen(qry.gen_qry(), decimate=False) with Gebco(data_dir=bathymetry_data_dir) as bathy:
tracks_depth = list(bathy.merge_tracks(tracks))def add_color(tracks):
for track in tracks:
# average depth across all positions in the track
avg_depth = sum(track["depth_metres"]) / len(track["depth_metres"])
if avg_depth <= 200:
track["color"] = "yellow" # continental shelf
elif avg_depth <= 2000:
track["color"] = "orange" # continental slope
elif avg_depth <= 6000:
track["color"] = "pink" # abyssal plain
else:
track["color"] = "red" # deep ocean trench
yield tracktracks_colored = add_color(tracks_depth)
if __name__ == "__main__":
aisdb.web_interface.visualize(
tracks_colored,
domain=domain,
visualearth=True,
open_browser=True,
)array([ -3.2, 14.6, 58.9, 210.5, 980.3, 1523.8])import aisdb
from aisdb import DBQuery
from aisdb.database.dbconn import PostgresDBConn
from datetime import datetime, timedelta
from aisdb.discretize.h3 import Discretizer
# PostgreSQL connection details (replace placeholders or use environment variables)
db_user = '<>' # PostgreSQL username
db_dbname = '<>' # PostgreSQL database/schema name
db_password = '<>' # PostgreSQL password
db_hostaddr = '127.0.0.1' # PostgreSQL host address (localhost shown)
dbconn = PostgresDBConn(
port=5555, # PostgreSQL port (5432 is the default; 5555 here is just an example)
user=db_user,
dbname=db_dbname,
hostaddr=db_hostaddr,
password=db_password,
)
# Spatial and temporal query window over the Gulf of St. Lawrence
xmin, ymin, xmax, ymax = -70, 45, -58, 53
start_time = datetime(2023, 8, 1)
end_time = datetime(2023, 8, 2)
# DBQuery takes the bounding box as four separate keyword arguments, not a
# single bbox list, and the callback picks the SQL "WHERE" clause it applies.
qry = DBQuery(
dbconn=dbconn,
start=start_time, end=end_time,
xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax,
callback=aisdb.database.sqlfcn_callbacks.in_time_bbox_validmmsi,
)
rowgen = qry.gen_qry()
# A resolution of 6 gives hexagons roughly the size of a small region; raise
# it for finer grids, lower it for coarser ones.
discretizer = Discretizer(resolution=6)
# TrackGen groups the raw rows into per-vessel tracks, and split_timedelta
# breaks long tracks into fixed-length segments so a single vessel's history
# doesn't blend into one enormous track.
tracks = aisdb.track_gen.TrackGen(rowgen, decimate=True)
tracks_segment = aisdb.track_gen.split_timedelta(tracks, timedelta(weeks=4))
# yield_tracks_discretized_by_indexes adds an 'h3_index' array to each track,
# aligned point-for-point with its 'lat' and 'lon' arrays.
tracks_with_indexes = discretizer.yield_tracks_discretized_by_indexes(tracks_segment)
for track in tracks_with_indexes:
print(f"H3 index for lat {track['lat'][0]}, lon {track['lon'][0]}: {track['h3_index'][0]}")
break
# Output: H3 index for lat 50.003334045410156, lon -66.76000213623047: 860e4d31fffffff# Inspect how resolution 6 hexagons scale with latitude and print edge lengths
discretizer.describe()Merge ERA5 reanalysis weather variables onto AIS vessel tracks with AISdb's WeatherDataStore, reading GRIB files from disk or fetching them from the Copernicus Climate Data Store.
This tutorial covers merging ERA5 weather variables with AIS vessel tracks using AISdb's WeatherDataStore. It reads GRIB files, either ones you already have on disk or ones fetched on demand from the Copernicus Climate Data Store (CDS), and matches wind, precipitation, and other reanalysis fields to each point in a track.
To work with the runnable notebook directly, see weather.ipynb in the AISdb repository.
You'll need a free Copernicus CDS account to access ERA5 data, obtained through ECMWF sign-up, and a working AISdb install. See the installation guide if you haven't set that up yet.
WeatherDataStore reads GRIB files through cfgrib/xarray, and downloading from CDS goes through cdsapi. Both are regular dependencies of AISdb's weather module, so a standard pip install aisdb covers them.
WeatherDataStore (from aisdb.weather.data_store) is the entry point for weather enrichment. You give it a list of ERA5 variable short names, a date range, and a folder to read GRIB files from (or write them to). From there it exposes two ways to pull weather values.
extract_weather(latitude, longitude, time) looks up a single point and returns a dictionary keyed by short name, e.g. {'10u': 5.2, '10v': 3.1}.
yield_tracks_with_weather(tracks) takes a generator of AISdb track dictionaries (the kind TrackGen produces) and yields each track back with a weather_data key added, containing the matched values for every point along the track.
GRIB files are expected to be named by month, yyyy-mm.grib or yyyy-mm.grib.zip (e.g. 2023-08.grib), sitting in the folder you pass as weather_data_path. WeatherDataStore works out which months it needs from your start/end range and loads them all.
ERA5 short names are ECMWF's compact identifiers for reanalysis variables, things like 10u for the 10 metre U wind component or tp for total precipitation. WeatherDataStore validates every short name you pass against its own lookup table and raises a ValueError immediately if one isn't recognized, so a typo fails fast rather than surfacing later as missing data. The full parameter list is documented on the .
WeatherDataStore doesn't care which backend your tracks came from, so a SQLiteDBConn works exactly the same way here.
Define the bounding box and time window, then convert the query rows into tracks with TrackGen
Wind, precipitation, and sea state all shape how vessels move. A ship might slow down or reroute in heavy weather, burn more fuel fighting a headwind, or take a longer path around a storm system entirely. Attaching ERA5 variables directly to AIS tracks lets you study those relationships point by point instead of guessing at conditions from a separate forecast archive, which is useful for route optimization, fuel-efficiency studies, or explaining anomalies in a vessel's speed profile.
Turn a jittery AIS track into a smooth path and a running velocity estimate with a constant-velocity Kalman filter in FilterPy, then flag GPS outliers with the filter's innovation.
Raw AIS positions jitter, drop out, and arrive on an uneven clock, so a plotted track shows saw-tooth zig-zags no vessel ever sailed. In this tutorial you build a constant-velocity Kalman filter with , run it over a real NOAA track pulled through AISdb, and get back a smoothed path, a speed estimate at every report, and a statistical test that flags spurious GPS fixes before they reach a chart or a downstream model.
Select a steady transit out of thousands of tracks using delta_knots
Set up a four-state constant-velocity filter and read H,
TrackGenWeatherDataStoreWeatherDataStore takes the short names you want, the date range, and the folder holding the matching GRIB files. In the 1.8.0-alpha release the GRIB files must already be on disk, one per month of your query range, named yyyy-mm.grib (a .zip of the same name also works). Download them from the Copernicus Climate Data Store beforehand, either through the web interface or with the cdsapi client.
10u and 10v are the 10 metre U wind component and 10 metre V wind component, respectively.
For a full set of tracks, yield_tracks_with_weather matches each point's latitude, longitude, and time against the loaded GRIB data and adds a weather_data dictionary to every track, keyed by the short names you requested.
Always call .close() when you're done. WeatherDataStore keeps the underlying xarray datasets open for the lifetime of the object, and closing them releases the file handles cleanly.

weather_data_store = WeatherDataStore(
short_names=['10u', '10v', 'tp'], # U wind, V wind, total precipitation
start=start_time,
end=end_time,
weather_data_path='.', # folder holding yyyy-mm.grib(.zip) files
)values = weather_data_store.extract_weather(50.0, -66.75, 1690858823)
print(values)
# {'10u': 1.97, '10v': -0.42, 'tp': array(...)}tracks_with_weather = weather_data_store.yield_tracks_with_weather(tracks)
for track in tracks_with_weather:
print(
f"'u-component' 10m wind for:\n"
f"lat: {track['lat'][0]}\n"
f"lon: {track['lon'][0]}\n"
f"time: {track['time'][0]}\n"
f"is {track['weather_data']['10u'][0]} m/s"
)
break
weather_data_store.close() # release the open GRIB datasets'u-component' 10m wind for:
lat: 50.003334045410156
lon: -66.76000213623047
time: 1690858823
is 1.9680767059326172 m/simport aisdb
from aisdb import DBQuery
from aisdb.database.dbconn import PostgresDBConn
from datetime import datetime
from aisdb.weather.data_store import WeatherDataStoredb_user = '' # DB user
db_dbname = 'aisviz' # DB schema
db_password = '' # DB password
db_hostaddr = '127.0.0.1' # DB host address
dbconn = PostgresDBConn(
port=5555, # PostgreSQL port
user=db_user, # PostgreSQL username
dbname=db_dbname, # PostgreSQL database
hostaddr=db_hostaddr, # PostgreSQL address
password=db_password, # PostgreSQL password
)xmin, ymin, xmax, ymax = -70, 45, -58, 53
gulf_bbox = [xmin, xmax, ymin, ymax]
start_time = datetime(2023, 8, 1)
end_time = datetime(2023, 8, 30)
qry = DBQuery(
dbconn=dbconn,
start=start_time, end=end_time,
xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax,
callback=aisdb.database.sqlfcn_callbacks.in_time_bbox_validmmsi,
)
rowgen = qry.gen_qry()
tracks = aisdb.track_gen.TrackGen(rowgen, decimate=True)RPFQHandle AIS's irregular reporting clock by rebuilding F and Q from each time gap
Flag GPS outliers with the filter's innovation instead of hand-tuned distance rules
One data file, the open NOAA day AIS_2020_01_01.zip, decoded to ./data/ais_2020_01_01.db via aisdb.decode_msgs exactly as in step 1 of the clustering tutorial. Every number and figure below is the output of running this page end to end on AISdb 1.8.0-alpha.
With the database in place, query the Gulf of Mexico window. decimate=False matters here, curve decimation would throw away exactly the irregular spacing the filter is designed to handle.
A constant-velocity model is easiest to read on a vessel that holds a course, so keep only genuine transits, 150 to 600 reports, no inter-report speed above 60 knots (GPS teleports), a median speed of 8 to 18 knots (cruising pace, not drifting or docked), at least 5 hours long, and take the longest survivor. delta_knots doubles as the teleport check (its maximum) and the cruising check (its median).
The state is [x, y, vx, vy] in local meters (an equirectangular projection is accurate over one track's extent, and meters keep velocity physical). H picks position out of the state because AIS observes position, not velocity; R encodes trust in each fix; P starts wide on velocity because it is initialized at zero with no evidence yet. Since reports arrive on an uneven clock, F and Q are functions of each elapsed gap rather than fixed matrices, with Q growing in the gap because a longer blind stretch leaves more room for the vessel to have turned.
accel_std is the one knob worth tuning, expected velocity change in meters per second squared. A steady cargo-style transit earns 0.1, letting the filter smooth through jitter; a maneuvering tug or fishing vessel needs closer to 0.5 so it does not fight real turns.
Loop through the reports in order, predict forward by the elapsed gap, update with the new fix, and record the state. Speed comes from the velocity components, and the inverse projection puts the smoothed path back into degrees for mapping.
The innovation is the gap between a measurement and the filter's prediction, judged against the uncertainty the filter already computed. A fix landing more than four sigma out is exactly the spurious jump denoising code hunts for, so reset the filter and make a second pass that tests each fix before accepting it.
Zero flags is the right answer here, not a bug, this vessel was selected for its steadiness. Run the identical pass over the day's loitering vessels and the flags land where they should. A stationary vessel with a 41-knot teleport in an otherwise clean track flagged 3 of 405 reports, and a corrupted transceiver whose fixes jump at up to 1352 knots flagged 153 of 646. Four sigma is conservative on purpose; tighten it to catch subtler drift at the cost of false positives on legitimate sharp turns.
The ringing over the first two hours looks alarming and is not. The state begins with vx = vy = 0, so the earliest steps show the filter swinging toward the true speed as evidence arrives; seed the initial velocity from the first two positions if you need a clean series from the first sample. From then on the estimate and the vessel's own SOG track each other closely, including through the stop, where the estimate floors near half a knot because anchored GPS jitter never quite reads as zero motion.
The two lines separate exactly where they should, at tight maneuvers, long reporting gaps, and any fix that reads far from its neighbors. Remember the filter smooths noise but not bias, a transceiver reporting a consistently offset position produces a faithfully smoothed offset track, so cross-check persistent lane divergences against other data.
A constant-velocity Kalman filter turns jittery, unevenly timed AIS reports into a smooth track plus a velocity estimate at every step.
Rebuilding F and Q from each report gap is what makes the filter native to AIS's irregular clock.
accel_std is the single knob that trades smoothness against responsiveness; match it to the vessel's maneuvering style.
The innovation test gives a principled GPS-outlier flag for free, no hand-tuned distance thresholds required.
Next, seq2seq in PyTorch learns to predict where a denoised, velocity-bearing track goes next.
FilterPy documentation: https://filterpy.readthedocs.io/
Roger Labbe, "Kalman and Bayesian Filters in Python": https://github.com/rlabbe/Kalman-and-Bayesian-Filters-in-Python
AISdb repository: https://github.com/MAPS-Lab/AISdb
pip install aisdb filterpy matplotlibfrom datetime import datetime
import numpy as np
import aisdb
from aisdb import SQLiteDBConn, DBQuery
from aisdb.database import sqlfcn_callbacks
from aisdb.gis import delta_knots
dbpath = './data/ais_2020_01_01.db' # built in the clustering tutorial
with SQLiteDBConn(dbpath=dbpath) as dbconn:
qry = DBQuery(dbconn=dbconn,
start=datetime(2020, 1, 1), end=datetime(2020, 1, 2),
xmin=-98, xmax=-80, ymin=24, ymax=31, # Gulf of Mexico
callback=sqlfcn_callbacks.in_bbox_time_validmmsi)
tracks = list(aisdb.track_gen.TrackGen(qry.gen_qry(), decimate=False))
n_positions = sum(t['time'].size for t in tracks)
print(f"loaded {len(tracks)} vessel tracks ({n_positions:,} positions)")loaded 5976 vessel tracks (3,080,231 positions)def steady(t):
if not (150 <= t['time'].size <= 600):
return False
speeds = delta_knots(t)
return (speeds.size > 0 and np.max(speeds) <= 60
and 8 <= np.median(speeds) <= 18
and (t['time'][-1] - t['time'][0]) / 3600 >= 5)
candidates = [t for t in tracks if steady(t)]
track = max(candidates, key=lambda t: t['time'].size)
print(f"{len(candidates)} candidates")
print(f"selected MMSI {track['mmsi']} with {track['time'].size} reports")141 candidates
selected MMSI 538090531 with 597 reportsfrom filterpy.kalman import KalmanFilter
from filterpy.common import Q_discrete_white_noise
lat0, lon0 = np.mean(track['lat']), np.mean(track['lon'])
meters_per_deg_lat = 111_320.0
meters_per_deg_lon = 111_320.0 * np.cos(np.radians(lat0))
x_m = (track['lon'] - lon0) * meters_per_deg_lon
y_m = (track['lat'] - lat0) * meters_per_deg_lat
t_s = track['time'].astype(float)
kf = KalmanFilter(dim_x=4, dim_z=2)
kf.x = np.array([x_m[0], y_m[0], 0.0, 0.0]) # first position, zero velocity
kf.P = np.diag([25.0, 25.0, 100.0, 100.0]) # unsure about velocity
kf.H = np.array([[1, 0, 0, 0], # we observe position only,
[0, 1, 0, 0]]) # never velocity
gps_std = 15.0 # terrestrial AIS position spread
kf.R = np.eye(2) * gps_std ** 2
def transition_matrix(dt):
return np.array([[1, 0, dt, 0], [0, 1, 0, dt],
[0, 0, 1, 0], [0, 0, 0, 1]])
def process_noise(dt, accel_std=0.1):
q1d = Q_discrete_white_noise(dim=2, dt=dt, var=accel_std ** 2)
Q = np.zeros((4, 4))
Q[0::2, 0::2] = q1d # x, vx block
Q[1::2, 1::2] = q1d # y, vy block
return Qsmoothed_x, smoothed_y = [x_m[0]], [y_m[0]]
smoothed_vx, smoothed_vy = [0.0], [0.0]
smoothed_t = [t_s[0]]
for i in range(1, len(t_s)):
dt = float(t_s[i] - t_s[i - 1])
if dt <= 0:
continue # skip duplicate or out-of-order timestamps
kf.F = transition_matrix(dt)
kf.Q = process_noise(dt)
kf.predict()
kf.update(np.array([x_m[i], y_m[i]]))
smoothed_x.append(kf.x[0])
smoothed_y.append(kf.x[1])
smoothed_vx.append(kf.x[2])
smoothed_vy.append(kf.x[3])
smoothed_t.append(t_s[i])
smoothed_x, smoothed_y = np.array(smoothed_x), np.array(smoothed_y)
smoothed_t = np.array(smoothed_t)
speed_knots = np.hypot(smoothed_vx, smoothed_vy) * 1.9438
smoothed_lon = smoothed_x / meters_per_deg_lon + lon0
smoothed_lat = smoothed_y / meters_per_deg_lat + lat0flagged = []
kf.x = np.array([x_m[0], y_m[0], 0.0, 0.0])
kf.P = np.diag([25.0, 25.0, 100.0, 100.0])
for i in range(1, len(t_s)):
dt = float(t_s[i] - t_s[i - 1])
if dt <= 0:
continue
kf.F = transition_matrix(dt)
kf.Q = process_noise(dt)
kf.predict()
innovation = np.array([x_m[i], y_m[i]]) - kf.H @ kf.x
innovation_std = np.sqrt(np.diag(kf.H @ kf.P @ kf.H.T + kf.R))
if np.any(np.abs(innovation) > 4 * innovation_std):
flagged.append(i)
kf.update(np.array([x_m[i], y_m[i]]))
print(f"{len(flagged)} of {len(t_s)} reports flagged as likely GPS outliers")0 of 597 reports flagged as likely GPS outliersimport matplotlib.pyplot as plt
plt.figure(figsize=(9, 4.5))
plt.plot((t_s - t_s[0]) / 3600, track['sog'], '.', color='gray',
markersize=3, alpha=0.5, label='AIS-reported SOG')
plt.plot((smoothed_t - t_s[0]) / 3600, speed_knots, '-', color='crimson',
linewidth=1.2, label='Kalman speed estimate')
plt.xlabel('hours since first report'); plt.ylabel('speed (knots)')
plt.legend(); plt.show()
plt.figure(figsize=(8, 8))
plt.plot(track['lon'], track['lat'], 'o-', color='gray', alpha=0.4,
markersize=3, label='raw AIS positions')
plt.plot(smoothed_lon, smoothed_lat, '-', color='crimson', linewidth=1.5,
label='Kalman-smoothed track')
plt.xlabel('longitude'); plt.ylabel('latitude')
plt.legend(); plt.show()

Find shipping lanes, anchorages, and vessel behavior groups in one day of NOAA AIS data by running DBSCAN and KMeans over AISdb tracks.
AIS gives you millions of position reports, but a raw stream of latitude and longitude says little on its own. In this tutorial you build two clustering pipelines on one open day of NOAA data over the Gulf of Mexico. DBSCAN pools every position to surface ports, anchorages, and fairways as dense hotspots, and KMeans groups whole tracks by movement features to separate vessels underway from vessels that barely move, the same machinery anomaly detection starts from.
Decode a NOAA day file into SQLite and pull vessel tracks with AISdb
Run DBSCAN with a haversine metric so eps means kilometers, not degrees
Reduce each track to a six-value movement feature vector with delta_knots
Choose the KMeans cluster count with a silhouette sweep and read the cluster profiles
One data file, the open NOAA day , decoded via aisdb.decode_msgs in step 1. Every number and figure below is the output of running this page end to end on AISdb 1.8.0-alpha.
decode_msgs accepts the compressed archive directly, so one call covers download to database, and the decode is a one-time cost (this run chewed through seven million messages on a heavily loaded shared server). The query window covers the Gulf of Mexico, a busy mix of port calls, offshore-supply runs, and through traffic, and TrackGen yields one dictionary per vessel with aligned lon, lat, time, and sog arrays, materialized with list(...) because both pipelines iterate the tracks twice.
DBSCAN needs no cluster count up front, finds arbitrarily shaped dense regions, and labels sparse points as noise instead of forcing them into a group. The detail that trips people up is the metric. haversine expects radians, so eps is also in radians, and a 1 km neighborhood becomes 1 / 6371 (Earth's mean radius in km). A seeded 200,000-point subsample keeps the ball-tree search fast and exactly reproducible.
Both parameters have physical meaning, kilometers and a point count, so you can reason about them instead of guessing. The noise points are mostly vessels in open transit whose positions never pile up densely enough to anchor a cluster.
Hotspots tell you where things happen, not how vessels behave. For behavior, reduce each track to a small vector, speed statistics, bounding-box extent, duration, and message count. The 80-knot sanity filter drops tracks with GPS teleports, which would otherwise blow up the speed features.
Standardize the columns first, KMeans measures Euclidean distance and the message count would otherwise dominate the knots. Then sweep k and keep the silhouette winner, which rewards clusters that are tight internally and well separated, and print each cluster's feature means to see what it represents.
The score peaks at k=2 and never recovers, so the dominant structure in a port-heavy day is binary. The profiles read it off directly, cluster 0 is 814 underway transits at about nine knots sweeping roughly a degree of latitude and longitude, cluster 1 is 5052 moored and anchored tracks averaging under a knot across a tenth of a degree.
The two-cluster answer is real but coarse. One day of port-heavy AIS is overwhelmingly stationary vessels, so the strongest available signal is moving versus not moving; a longer window plus features like heading variance and turning rate is what separates transit types and fishing patterns. Run tracks through the denoising in first when precision matters, and treat any unsupervised result as a hypothesis to validate against charts and domain knowledge.
DBSCAN with a haversine metric finds ports, anchorages, and fairways with parameters you can reason about in kilometers and point counts.
Six movement features per track plus standardization is enough for KMeans to separate underway traffic from moored vessels.
The silhouette sweep turns "how many clusters" from a guess into a defensible choice, but sanity-check that the winner is interpretable.
Subsample pooled positions with a seeded generator; haversine DBSCAN over millions of raw points is slow and memory hungry.
Next, models how a single track evolves through time and smooths its noise away.
scikit-learn clustering user guide:
AISdb repository:
pip install aisdb scikit-learn matplotlibimport urllib.request
from datetime import datetime
from pathlib import Path
import numpy as np
import aisdb
from aisdb import SQLiteDBConn, DBQuery
from aisdb.database import sqlfcn_callbacks
from aisdb.gis import delta_knots
Path('./data').mkdir(exist_ok=True)
zip_path = Path('./data/AIS_2020_01_01.zip')
dbpath = './data/ais_2020_01_01.db'
url = 'https://coast.noaa.gov/htdata/CMSP/AISDataHandler/2020/AIS_2020_01_01.zip'
if not zip_path.exists():
urllib.request.urlretrieve(url, zip_path)
if not Path(dbpath).exists():
with SQLiteDBConn(dbpath=dbpath) as dbconn:
aisdb.decode_msgs(filepaths=[str(zip_path)], dbconn=dbconn,
source='NOAA', verbose=True)
with SQLiteDBConn(dbpath=dbpath) as dbconn:
qry = DBQuery(dbconn=dbconn,
start=datetime(2020, 1, 1), end=datetime(2020, 1, 2),
xmin=-98, xmax=-80, ymin=24, ymax=31, # Gulf of Mexico
callback=sqlfcn_callbacks.in_bbox_time_validmmsi)
tracks = list(aisdb.track_gen.TrackGen(qry.gen_qry(), decimate=False))
n_positions = sum(len(t['time']) for t in tracks)
print(f"loaded {len(tracks)} vessel tracks ({n_positions:,} positions)")AIS_2020_01_01.csv count: 7040389 elapsed: 2297.13s rate: 3065 msgs/s
loaded 5976 vessel tracks (3,080,231 positions)from sklearn.cluster import DBSCAN
coords = np.concatenate([np.column_stack([t['lat'], t['lon']]) for t in tracks])
rng = np.random.default_rng(42) # reproducible subsample
if coords.shape[0] > 200_000:
coords = coords[rng.choice(coords.shape[0], size=200_000, replace=False)]
eps_km = 1.0 # neighborhood radius
db = DBSCAN(eps=eps_km / 6371.0, min_samples=25,
metric='haversine', algorithm='ball_tree',
).fit(np.radians(coords))
labels = db.labels_
n_clusters = len(set(labels)) - (1 if -1 in labels else 0)
print(f"{n_clusters} spatial hotspots, {int(np.sum(labels == -1))} noise points")555 spatial hotspots, 26630 noise pointsMAX_PLAUSIBLE_KN = 80.0 # faster segments are GPS jumps
def track_features(track):
"""One fixed-length movement feature vector per track."""
speeds = delta_knots(track) # n-1 speeds in knots
if speeds.size == 0 or speeds.max() > MAX_PLAUSIBLE_KN:
return None
times = np.asarray(track['time'], dtype=float)
return np.array([
np.mean(speeds), # mean speed (knots)
np.median(speeds), # median speed, spike-robust
track['lat'].max() - track['lat'].min(), # N-S extent (degrees)
track['lon'].max() - track['lon'].min(), # E-W extent (degrees)
(times[-1] - times[0]) / 3600.0, # duration (hours)
float(times.size), # message count
])
valid = [(t, f) for t in tracks if (f := track_features(t)) is not None]
tracks_kept = [t for t, _ in valid]
X = np.vstack([f for _, f in valid])
print(f"feature matrix: {X.shape[0]} tracks x {X.shape[1]} features")
print(f"dropped {len(tracks) - X.shape[0]} tracks failing the 80 kn sanity check")feature matrix: 5866 tracks x 6 features
dropped 110 tracks failing the 80 kn sanity checkfrom sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
X_scaled = StandardScaler().fit_transform(X)
best_k, best_score, best_model = None, -1.0, None
for k in range(2, 9):
model = KMeans(n_clusters=k, n_init=10, random_state=42)
score = silhouette_score(X_scaled, model.fit_predict(X_scaled))
print(f"k={k}: silhouette={score:.3f}")
if score > best_score:
best_k, best_score, best_model = k, score, model
print(f"best k = {best_k} (silhouette {best_score:.3f})")
labels_km = best_model.labels_
feature_names = ['mean_kn', 'median_kn', 'lat_ext', 'lon_ext', 'dur_hr', 'n_msg']
for c in range(best_k):
members = X[labels_km == c]
profile = dict(zip(feature_names, members.mean(axis=0).round(2).tolist()))
print(f"cluster {c}: {members.shape[0]} tracks -> {profile}")k=2: silhouette=0.580
k=3: silhouette=0.512
k=4: silhouette=0.481
k=5: silhouette=0.493
k=6: silhouette=0.533
k=7: silhouette=0.540
k=8: silhouette=0.546
best k = 2 (silhouette 0.580)
cluster 0: 814 tracks -> {'mean_kn': 8.76, 'median_kn': 9.27, 'lat_ext': 0.87, 'lon_ext': 1.07, 'dur_hr': 14.14, 'n_msg': 386.64}
cluster 1: 5052 tracks -> {'mean_kn': 0.89, 'median_kn': 0.41, 'lat_ext': 0.08, 'lon_ext': 0.1, 'dur_hr': 21.44, 'n_msg': 544.52}import matplotlib.pyplot as plt
plt.figure(figsize=(8, 8))
plt.scatter(coords[:, 1], coords[:, 0], c=labels, s=2, cmap='tab20')
plt.xlabel('longitude'); plt.ylabel('latitude')
plt.title('DBSCAN spatial hotspots (noise in one color)')
plt.show()
plt.figure(figsize=(8, 8))
for t, c in zip(tracks_kept, labels_km):
plt.plot(t['lon'], t['lat'], color=f'C{c}', linewidth=0.4, alpha=0.4)
handles = [plt.Line2D([0], [0], color=f'C{c}', linewidth=2,
label=f'cluster {c} ({int(np.sum(labels_km == c))} tracks)')
for c in range(best_k)]
plt.legend(handles=handles, loc='upper left')
plt.xlabel('longitude'); plt.ylabel('latitude')
plt.title('KMeans behavior groups on per-track features')
plt.show()

A GRU autoencoder in Keras that compresses four hours of vessel track and decodes an eight-hour forecast, trained with probabilistic teacher forcing.
This tutorial builds a GRU autoencoder in Keras that reads four hours of a vessel's AIS track and forecasts the next eight hours of movement. Training uses probabilistic teacher forcing, feeding the decoder a mix of ground truth and its own predictions so convergence is faster and long-horizon forecasts stay stable. You walk away able to window raw AIS into supervised sequence pairs and train a sequence-to-sequence forecaster with a loss that thinks in meters.
Querying, segmenting, and interpolating a year of AIS tracks with AISdb
Windowing trajectories into fixed input and output sequences with delta features
Building a GRU encoder-decoder with a probabilistic teacher-forcing layer in Keras
Training with a haversine-based custom loss and standard Keras callbacks
Evaluating forecasts as physical distance errors on held-out vessels
The run shown on this page queried a private regional AISdb database covering Atlantic Canada, ais-atlantic-canada.db, built from a full year of decoded AIS traffic (2021-01-01 through 2021-12-31), because forecasting eight hours from four needs enough long, clean voyages to fill the training buckets built below. If you do not have a private database, decode the open NOAA day file () into SQLite as shown in , point get_tracks(dbname, "01012020", "02012020") at it, and add xmin=-98, xmax=-80, ymin=24, ymax=31 to the DBQuery call inside qry_database. One day of AIS exercises the full pipeline end to end but yields far fewer usable trajectories and a weaker fit than the full-year run shown here. You also need the Natural Earth 50m land shapefile (ne_50m_land.shp) under shapes/, a free Weights & Biases account for training logs, and a notebook environment, since the evaluation renders interactive Plotly figures.
Everything downstream assumes these imports, a fixed random seed, and four working folders for the database, shapefiles, cached datasets, and model weights.
AISdb pulls the raw messages, splits tracks on 24-hour silences, drops speed and distance outliers, and interpolates every position to a fixed 5-minute cadence, which the windowing below depends on. A vessel can appear in several disjoint tracks after splitting, so voyages groups the segments by MMSI.
The dataset covers the Atlantic Canada receiver network, roughly a 100 km radius around each station.
Interpolation and decoding glitches can place positions inland, and a forecaster trained on impossible tracks learns impossible motion. Any track with a single point on land is removed, in parallel across MMSIs, and the curated result is cached to disk so the filter runs once.
Most MMSIs have between 1 and 49 segments while a few reach 176, so a naive split would let segment-rich vessels dominate. Categorizing voyages as long-term (30 or more segments) or short-term and splitting each bucket 80/20 keeps both behaviors represented in both sets.
The model takes 4 hours of input (48 steps at the 5-minute cadence) and predicts 8 hours (96 steps), so every voyage shorter than 144 steps is dropped and the rest gain delta features that expose the rate of change. A window then slides one step at a time along each voyage, producing overlapping input/output pairs, and each input window gets a straightness score (straight-line over along-track haversine distance, inverted and scaled) so complex, twisty samples can be weighted more heavily during training.
Three chained transforms bring every feature onto a comparable scale. Longitude, latitude, COG, and SOG first map to [0, 1] with domain bounds for Atlantic Canada, then standardization centers the data to fight vanishing gradients, and a final zero-one pass matches the range the activations expect. denormalize_x reverses the chain when plotting inputs later.
A GRU autoencoder compresses the input sequence into a single hidden state and decodes it into the forecast, and a GRU (Cho et al., 2014, ) handles the temporal dependencies in each direction. The twist is the ProbabilisticTeacherForcing layer, which at every decoder step randomly feeds either the ground-truth previous position or the model's own previous output, governed by a mixing probability. Two graphs share the same trained layers, a training model that takes the ground-truth decoder input and a clean inference model that runs from the encoder alone.
Losses on normalized coordinates hide the physical size of an error, so the custom loss denormalizes both tensors back to degrees and works in meters through a TensorFlow haversine. It also computes step-to-step distance consistency and an input-to-output continuity penalty, available in the commented weighted combination, while the run shown trained on the RMSE term alone.
Training pads the decoder's ground-truth input with a leading zero step, fixes the mixing probability at 0.5, and lets the callbacks manage the rest, with ReduceLROnPlateau lowering the learning rate when validation stalls, EarlyStopping restoring the best weights, ModelCheckpoint saving them, and WandbMetricsLogger streaming metrics to Weights & Biases (pass skip_wandb=True to drop it). The straightness weights from Step 5 plug in through the commented sample_weight argument, unused in the run shown.
The evaluation predicts the held-out set once, denormalizes everything back to real coordinates, plots sample forecasts against the ground truth, and reports distance errors through the haversine plus standard regression metrics. The slicing with [:, 1:] aligns the 96-step targets with the 95-step teacher-forced outputs.
Each of the four plotted samples shows the green input track, the blue ground truth, and the red forecast, with per-trajectory min, max, mean, and median distance errors in the title. The console output then aggregates those distance errors across every test trajectory and adds R squared, MAE, MSE, RMSE, MAPE, and explained variance over all predicted coordinates. Absolute numbers depend on the database behind the run, the vessel mix, and the region, so compare the mean and median distance errors of your own runs before and after a change rather than chasing a universal benchmark, and expect visibly weaker forecasts from the one-day NOAA drop-in than from a full-year regional database.
Probabilistic teacher forcing trains the decoder on a mix of ground truth and its own predictions, speeding convergence while keeping the inference graph free of ground-truth inputs, and the two graphs share weights by sharing layers.
A loss that denormalizes to meters through the haversine judges forecasts in physical units, and extra terms for speed-consistency and continuity are wired in when plain RMSE is not enough.
Windowing with deltas, land filtering, and a length-balanced 80/20 split matter as much as the architecture, since a forecaster learns whatever the samples contain.
Next, learns a fixed-length representation of an entire voyage instead of stepping through it one position at a time.
pip install aisdb tensorflow geopandas shapely plotly wandb scikit-learn tqdmimport os
import random
import pickle as pkl
import multiprocessing
from multiprocessing import Pool
from functools import partial
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timedelta
import numpy as np
import geopandas as gpd
from shapely.geometry import Point
from tqdm import tqdm
from IPython.display import display
import plotly.graph_objects as go
import aisdb
import tensorflow as tf
from tensorflow.keras import backend as K
from tensorflow.keras.layers import Layer, Input, GRU, LSTM, RepeatVector, TimeDistributed, Dense
from tensorflow.keras.models import Model
from tensorflow.keras.optimizers import AdamW
from tensorflow.keras.callbacks import ReduceLROnPlateau, EarlyStopping
import wandb
from sklearn.metrics import (
r2_score, mean_squared_error, mean_absolute_error,
explained_variance_score, mean_absolute_percentage_error)
seed_value = 42
random.seed(seed_value)
np.random.seed(seed_value)
tf.random.set_seed(seed_value)
ROOT = os.getcwd()
PATH = "data" # holds the SQLite database
SHAPES = "shapes" # holds the Natural Earth land shapefile
ESRF = "cache" # holds intermediate pickled datasets
MODELS = "models" # holds trained weights and training histories
for folder in (PATH, SHAPES, ESRF, MODELS):
os.makedirs(os.path.join(ROOT, folder), exist_ok=True)def qry_database(dbname, start_time, stop_time):
d_threshold = 200000 # max distance (in meters) between two messages of a track
s_threshold = 50 # max speed (in knots) between two AIS messages of a track
t_threshold = timedelta(hours=24) # max time between messages of a track
try:
with aisdb.DBConn(dbpath=os.path.join(ROOT, PATH, dbname)) as dbconn:
tracks = aisdb.TrackGen(
aisdb.DBQuery(
dbconn=dbconn,
callback=aisdb.database.sqlfcn_callbacks.in_timerange,
start=start_time, end=stop_time).gen_qry(),
decimate=False) # trajectory compression
tracks = aisdb.split_timedelta(tracks, t_threshold)
tracks = aisdb.encode_greatcircledistance(tracks, distance_threshold=d_threshold, speed_threshold=s_threshold)
tracks = aisdb.interp_time(tracks, step=timedelta(minutes=5))
return list(tracks) # list of segmented pre-processed tracks
except SyntaxError as e: return [] # no results for query
def get_tracks(dbname, start_ddmmyyyy, stop_ddmmyyyy):
stop_time = datetime.strptime(stop_ddmmyyyy, "%d%m%Y")
start_time = datetime.strptime(start_ddmmyyyy, "%d%m%Y")
return qry_database(dbname, start_time, stop_time)
tracks = get_tracks("ais-atlantic-canada.db", "01012021", "31122021")
voyages = defaultdict(list)
for track in tracks:
voyages[track["mmsi"]].append(track)
voyages = dict(voyages)land_polygons = gpd.read_file(os.path.join(ROOT, SHAPES, "ne_50m_land.shp"))
def is_on_land(lat, lon, land_polygons):
return land_polygons.contains(Point(lon, lat)).any()
def is_track_on_land(track, land_polygons):
for lat, lon in zip(track["lat"], track["lon"]):
if is_on_land(lat, lon, land_polygons):
return True
return False
def process_mmsi(item, polygons):
mmsi, tracks = item
filtered_tracks = [t for t in tracks if not is_track_on_land(t, polygons)]
return mmsi, filtered_tracks, len(tracks)
def process_voyages(voyages, land_polygons):
def process_mmsi_callback(result, progress_bar):
mmsi, filtered_tracks, _ = result
voyages[mmsi] = filtered_tracks
progress_bar.update(1)
progress_bar = tqdm(total=len(voyages), desc="MMSIs processed")
with ThreadPoolExecutor(max_workers=multiprocessing.cpu_count()) as executor:
futures = {executor.submit(process_mmsi, item, land_polygons): item for item in voyages.items()}
for future in as_completed(futures):
process_mmsi_callback(future.result(), progress_bar)
progress_bar.close()
return voyages
file_name = "curated-ais.pkl"
full_path = os.path.join(ROOT, ESRF, file_name)
if not os.path.exists(full_path):
voyages = process_voyages(voyages, land_polygons)
pkl.dump(voyages, open(full_path, "wb"))
else: voyages = pkl.load(open(full_path, "rb"))
voyages_counts = {k: len(voyages[k]) for k in voyages.keys()}long_term_voyages, short_term_voyages = [], []
for k in voyages_counts:
if voyages_counts[k] < 30:
short_term_voyages.append(k)
else: long_term_voyages.append(k)
random.shuffle(short_term_voyages)
random.shuffle(long_term_voyages)
train_voyage, test_voyage = {}, {}
for i, k in enumerate(short_term_voyages):
if i < int(0.8 * len(short_term_voyages)):
train_voyage[k] = voyages[k]
else: test_voyage[k] = voyages[k]
for i, k in enumerate(long_term_voyages):
if i < int(0.8 * len(long_term_voyages)):
train_voyage[k] = voyages[k]
else: test_voyage[k] = voyages[k]INPUT_TIMESTEPS = 48 # 4 hours * 12 AIS messages/h
INPUT_VARIABLES = 4 # Longitude, Latitude, COG, and SOG
OUTPUT_TIMESTEPS = 96 # 8 hours * 12 AIS messages/h
OUTPUT_VARIABLES = 2 # Longitude and Latitude
NUM_WORKERS = multiprocessing.cpu_count()
INPUT_VARIABLES *= 2 # double the features with deltas
def filter_and_transform_voyages(voyages):
filtered_voyages = {}
for k, v in voyages.items():
voyages_track = []
for voyage in v:
if len(voyage["time"]) > (INPUT_TIMESTEPS + OUTPUT_TIMESTEPS):
mtx = np.vstack([voyage["lon"], voyage["lat"],
voyage["cog"], voyage["sog"]]).T
deltas = np.diff(mtx, axis=0)
deltas = np.vstack([np.zeros(deltas.shape[1]), deltas])
mtx = np.hstack([mtx, deltas])
voyages_track.append(mtx)
if len(voyages_track) > 0:
filtered_voyages[k] = voyages_track
return filtered_voyages
train_voyage = filter_and_transform_voyages(train_voyage)
test_voyage = filter_and_transform_voyages(test_voyage)def haversine_distance(lon_1, lat_1, lon_2, lat_2):
lon_1, lat_1, lon_2, lat_2 = map(np.radians, [lon_1, lat_1, lon_2, lat_2])
a = np.sin((lat_2 - lat_1) / 2) ** 2 + np.cos(lat_1) * np.cos(lat_2) * np.sin((lon_2 - lon_1) / 2) ** 2
return (2 * np.arcsin(np.sqrt(a))) * 6371000 # R: 6,371,000 meters
def trajectory_straightness(x):
start_point, end_point = x[0, :2], x[-1, :2]
x_coordinates, y_coordinates = x[:-1, 0], x[:-1, 1]
x_coordinates_next, y_coordinates_next = x[1:, 0], x[1:, 1]
consecutive_distances = np.array(haversine_distance(x_coordinates, y_coordinates, x_coordinates_next, y_coordinates_next))
straight_line_distance = np.array(haversine_distance(start_point[0], start_point[1], end_point[0], end_point[1]))
result = straight_line_distance / np.sum(consecutive_distances)
return result if not np.isnan(result) else 1
def process_voyage(voyage, mmsi, max_size, overlap_size=1):
straightness_ratios, mmsis, x, y = [], [], [], []
for j in range(0, voyage.shape[0] - max_size, 1):
x_sample = voyage[(0 + j):(INPUT_TIMESTEPS + j)]
y_sample = voyage[(INPUT_TIMESTEPS + j - overlap_size):(max_size + j), 0:OUTPUT_VARIABLES]
straightness = trajectory_straightness(x_sample)
straightness_ratios.append(straightness)
x.append(x_sample.T)
y.append(y_sample.T)
mmsis.append(mmsi)
return straightness_ratios, mmsis, x, y
def process_data(voyages):
max_size = INPUT_TIMESTEPS + OUTPUT_TIMESTEPS
def process_voyage_callback(result, pbar):
pbar.update(1)
return result
with Pool(NUM_WORKERS) as pool, tqdm(total=sum(len(v) for v in voyages.values()), desc="Voyages") as pbar:
results = []
for mmsi in voyages:
for voyage in voyages[mmsi]:
callback = partial(process_voyage_callback, pbar=pbar)
results.append(pool.apply_async(process_voyage, (voyage, mmsi, max_size), callback=callback))
pool.close()
pool.join()
straightness_ratios, mmsis, x, y = [], [], [], []
for result in results:
s_ratios, s_mmsis, s_x, s_y = result.get()
straightness_ratios.extend(s_ratios)
mmsis.extend(s_mmsis)
x.extend(s_x)
y.extend(s_y)
x, y = np.stack(x), np.stack(y)
x, y = np.transpose(x, (0, 2, 1)), np.transpose(y, (0, 2, 1))
straightness_ratios = np.array(straightness_ratios)
min_straightness, max_straightness = np.min(straightness_ratios), np.max(straightness_ratios)
scaled_straightness_ratios = (straightness_ratios - min_straightness) / (max_straightness - min_straightness)
scaled_straightness_ratios = 1. - scaled_straightness_ratios
print(f"Final number of samples = {len(x)}", end="\n\n")
return mmsis, x, y, scaled_straightness_ratios
mmsi_train, x_train, y_train, straightness_ratios = process_data(train_voyage)
mmsi_test, x_test, y_test, _ = process_data(test_voyage)def normalize_dataset(x_train, x_test, y_train,
lat_min=42, lat_max=52, lon_min=-70, lon_max=-50, max_sog=50):
def normalize(arr, min_val, max_val):
return (arr - min_val) / (max_val - min_val)
# Initial normalization
x_train[:, :, :2] = normalize(x_train[:, :, :2], np.array([lon_min, lat_min]), np.array([lon_max, lat_max]))
y_train[:, :, :2] = normalize(y_train[:, :, :2], np.array([lon_min, lat_min]), np.array([lon_max, lat_max]))
x_test[:, :, :2] = normalize(x_test[:, :, :2], np.array([lon_min, lat_min]), np.array([lon_max, lat_max]))
x_train[:, :, 2:4] = x_train[:, :, 2:4] / np.array([360, max_sog])
x_test[:, :, 2:4] = x_test[:, :, 2:4] / np.array([360, max_sog])
# Standardize X and Y
x_mean, x_std = np.mean(x_train, axis=(0, 1)), np.std(x_train, axis=(0, 1))
y_mean, y_std = np.mean(y_train, axis=(0, 1)), np.std(y_train, axis=(0, 1))
x_train = (x_train - x_mean) / x_std
y_train = (y_train - y_mean) / y_std
x_test = (x_test - x_mean) / x_std
# Final zero-one normalization
x_min, x_max = np.min(x_train, axis=(0, 1)), np.max(x_train, axis=(0, 1))
y_min, y_max = np.min(y_train, axis=(0, 1)), np.max(y_train, axis=(0, 1))
x_train = (x_train - x_min) / (x_max - x_min)
y_train = (y_train - y_min) / (y_max - y_min)
x_test = (x_test - x_min) / (x_max - x_min)
return x_train, x_test, y_train, y_mean, y_std, y_min, y_max, x_mean, x_std, x_min, x_max
x_train, x_test, y_train, y_mean, y_std, y_min, y_max, x_mean, x_std, x_min, x_max = normalize_dataset(x_train, x_test, y_train)
def denormalize_x(x_data, x_mean, x_std, x_min, x_max,
lat_min=42, lat_max=52, lon_min=-70, lon_max=-50):
x_data = x_data * (x_max - x_min) + x_min # reverse zero-one normalization
x_data = x_data * x_std + x_mean # reverse standardization
x_data[:, :, 0] = x_data[:, :, 0] * (lon_max - lon_min) + lon_min
x_data[:, :, 1] = x_data[:, :, 1] * (lat_max - lat_min) + lat_min
return x_datatf.keras.backend.clear_session()
_ = wandb.login(force=True)
class ProbabilisticTeacherForcing(Layer):
def __init__(self, **kwargs):
super(ProbabilisticTeacherForcing, self).__init__(**kwargs)
def call(self, inputs):
decoder_gt_input, decoder_output, mixing_prob = inputs
mixing_prob = tf.expand_dims(mixing_prob, axis=-1) # add a dimension for broadcasting
mixing_prob = tf.broadcast_to(mixing_prob, tf.shape(decoder_gt_input))
return tf.where(tf.random.uniform(tf.shape(decoder_gt_input)) < mixing_prob, decoder_gt_input, decoder_output)
def build_model(rnn_unit="GRU", hidden_size=64):
encoder_input = Input(shape=(INPUT_TIMESTEPS, INPUT_VARIABLES), name="Encoder_Input")
decoder_gt_input = Input(shape=((OUTPUT_TIMESTEPS - 1), OUTPUT_VARIABLES), name="Decoder-GT-Input")
mixing_prob_input = Input(shape=(1,), name="Mixing_Probability")
# Encoder
encoder_gru = eval(rnn_unit)(hidden_size, activation="relu", name="Encoder")(encoder_input)
repeat_vector = RepeatVector((OUTPUT_TIMESTEPS - 1), name="Repeater")(encoder_gru)
# Inference Decoder
decoder_gru = eval(rnn_unit)(hidden_size, activation="relu", return_sequences=True, name="Decoder")
decoder_output = decoder_gru(repeat_vector, initial_state=encoder_gru)
# Adjust decoder_output shape
dense_output_adjust = TimeDistributed(Dense(OUTPUT_VARIABLES), name="Output_Adjust")
adjusted_decoder_output = dense_output_adjust(decoder_output)
# Training Decoder
decoder_gru_tf = eval(rnn_unit)(hidden_size, activation="relu", return_sequences=True, name="Decoder-TF")
probabilistic_tf_layer = ProbabilisticTeacherForcing(name="Probabilistic_Teacher_Forcing")
mixed_input = probabilistic_tf_layer([decoder_gt_input, adjusted_decoder_output, mixing_prob_input])
tf_output = decoder_gru_tf(mixed_input, initial_state=encoder_gru)
tf_output = dense_output_adjust(tf_output)
training_model = Model(inputs=[encoder_input, decoder_gt_input, mixing_prob_input], outputs=tf_output, name="Training")
inference_model = Model(inputs=encoder_input, outputs=adjusted_decoder_output, name="Inference")
return training_model, inference_model
training_model, model = build_model()def denormalize_y(y_data, y_mean, y_std, y_min, y_max, lat_min=42, lat_max=52, lon_min=-70, lon_max=-50):
scales = tf.constant([lon_max - lon_min, lat_max - lat_min], dtype=tf.float32)
biases = tf.constant([lon_min, lat_min], dtype=tf.float32)
y_data = y_data * (y_max - y_min) + y_min # reverse zero-one normalization
y_data = y_data * y_std + y_mean # reverse standardization
return y_data * scales + biases # reverse initial normalization
def haversine_distance(lon1, lat1, lon2, lat2):
lon1, lat1, lon2, lat2 = [tf.math.multiply(x, tf.divide(tf.constant(np.pi), 180.)) for x in [lon1, lat1, lon2, lat2]]
a = tf.math.square(tf.math.sin((lat2 - lat1) / 2.)) + tf.math.cos(lat1) * tf.math.cos(lat2) * tf.math.square(tf.math.sin((lon2 - lon1) / 2.))
return 2 * 6371000 * tf.math.asin(tf.math.sqrt(a)) # The earth radius is 6,371,000 meters
def custom_loss(y_true, y_pred):
tf.debugging.check_numerics(y_true, "y_true contains NaNs")
tf.debugging.check_numerics(y_pred, "y_pred contains NaNs")
# Denormalize true and predicted y
y_true_denorm = denormalize_y(y_true, y_mean, y_std, y_min, y_max)
y_pred_denorm = denormalize_y(y_pred, y_mean, y_std, y_min, y_max)
# Haversine distance between consecutive true and predicted positions
true_dist = haversine_distance(y_true_denorm[:, 1:, 0], y_true_denorm[:, 1:, 1], y_true_denorm[:, :-1, 0], y_true_denorm[:, :-1, 1])
pred_dist = haversine_distance(y_pred_denorm[:, 1:, 0], y_pred_denorm[:, 1:, 1], y_pred_denorm[:, :-1, 0], y_pred_denorm[:, :-1, 1])
# Convert maximum speed from knots to meters per 5 minutes
max_speed_m_per_5min = 50 * 1.852 * 1000 * 5 / 60
# Penalize predicted step distances beyond the maximum possible distance
dist_diff = tf.abs(true_dist - pred_dist)
dist_diff = tf.where(pred_dist > max_speed_m_per_5min, pred_dist - max_speed_m_per_5min, dist_diff)
# Penalty for the first output coordinate not matching the last input
input_output_diff = haversine_distance(y_true_denorm[:, 0, 0], y_true_denorm[:, 0, 1], y_pred_denorm[:, 0, 0], y_pred_denorm[:, 0, 1])
# RMSE excluding the first element
rmse = K.sqrt(K.mean(K.square(y_true_denorm[:, 1:, :] - y_pred_denorm[:, 1:, :]), axis=1))
# return 0.25 * K.mean(input_output_diff) + 0.35 * K.mean(dist_diff) + 0.40 * K.mean(rmse)
return K.mean(rmse)
def compile_model(model, learning_rate, clipnorm, jit_compile, skip_summary=False):
optimizer = AdamW(learning_rate=learning_rate, clipnorm=clipnorm, jit_compile=jit_compile)
model.compile(optimizer=optimizer, loss=custom_loss, metrics=["mae", "mape"], weighted_metrics=[], jit_compile=jit_compile)
if not skip_summary: model.summary()
compile_model(training_model, learning_rate=0.001, clipnorm=1, jit_compile=True)
compile_model(model, learning_rate=0.001, clipnorm=1, jit_compile=True)def create_callbacks(model_name, monitor="val_loss", factor=0.2, lr_patience=3, ep_patience=12, min_lr=0, verbose=0, restore_best_weights=True, skip_wandb=False):
return ([wandb.keras.WandbMetricsLogger()] if not skip_wandb else []) + [
ReduceLROnPlateau(monitor=monitor, factor=factor, patience=lr_patience, min_lr=min_lr, verbose=verbose),
EarlyStopping(monitor=monitor, patience=ep_patience, verbose=verbose, restore_best_weights=restore_best_weights),
tf.keras.callbacks.ModelCheckpoint(os.path.join(ROOT, MODELS, model_name), monitor="val_loss", mode="min", save_best_only=True, verbose=verbose)]
def train_model(model, x_train, y_train, batch_size, epochs, validation_split, model_name):
run = wandb.init(project="kAISdb", anonymous="allow")
mixing_prob = 0.5 # probability of feeding the decoder ground truth
# Match y_train to the decoder output length
y_train = y_train[:, :(OUTPUT_TIMESTEPS - 1), :]
# Ground-truth decoder input, padded with a zero step at the front
decoder_ground_truth_input_data = (np.zeros((y_train.shape[0], 1, y_train.shape[2])), y_train[:, :-1, :])
decoder_ground_truth_input_data = np.concatenate(decoder_ground_truth_input_data, axis=1)
try:
with tf.device(tf.test.gpu_device_name()):
training_model.fit([x_train, decoder_ground_truth_input_data, np.full((x_train.shape[0], 1), mixing_prob)], y_train, batch_size=batch_size, epochs=epochs,
verbose=2, validation_split=validation_split, callbacks=create_callbacks(model_name))
# , sample_weight=straightness_ratios)
except KeyboardInterrupt as e:
print("\nRestoring best weights [...]")
training_model.load_weights(model_name)
for layer in model.layers: # transfer weights to the inference model
if layer.name in [l.name for l in training_model.layers]:
layer.set_weights(training_model.get_layer(layer.name).get_weights())
run.finish()
model_name = "TF-GRU-AE.h5"
train_model(model, x_train, y_train, batch_size=1024,
epochs=250, validation_split=0.2,
model_name=model_name)def evaluate_model(model, x_test, y_test, y_mean, y_std, y_min, y_max, y_pred=None):
def single_trajectory_error(y_test, y_pred, index):
distances = haversine_distance(y_test[index, :, 0], y_test[index, :, 1], y_pred[index, :, 0], y_pred[index, :, 1])
return np.min(distances), np.max(distances), np.mean(distances), np.median(distances)
def all_trajectory_error(y_test, y_pred):
errors = [single_trajectory_error(y_test[:, 1:], y_pred, i) for i in range(y_test.shape[0])]
min_errors, max_errors, mean_errors, median_errors = zip(*errors)
return min(min_errors), max(max_errors), np.mean(mean_errors), np.median(median_errors)
def plot_trajectory(x_test, y_test, y_pred, sample_index):
min_error, max_error, mean_error, median_error = single_trajectory_error(y_test, y_pred, sample_index)
fig = go.Figure()
fig.add_trace(go.Scatter(x=x_test[sample_index, :, 0], y=x_test[sample_index, :, 1], mode="lines", name="Input Data", line=dict(color="green")))
fig.add_trace(go.Scatter(x=y_test[sample_index, :, 0], y=y_test[sample_index, :, 1], mode="lines", name="Ground Truth", line=dict(color="blue")))
fig.add_trace(go.Scatter(x=y_pred[sample_index, :, 0], y=y_pred[sample_index, :, 1], mode="lines", name="Forecasted Trajectory", line=dict(color="red")))
fig.update_layout(title=f"Sample Index: {sample_index} | Distance Errors (in meters):<br>Min: {min_error:.2f}m, Max: {max_error:.2f}m, "
f"Mean: {mean_error:.2f}m, Median: {median_error:.2f}m", xaxis_title="Longitude", yaxis_title="Latitude",
plot_bgcolor="#e4eaf0", paper_bgcolor="#fcfcfc", width=700, height=600)
max_lon, max_lat = -58.705587131108196, 47.89066160591873
min_lon, min_lat = -61.34247286889181, 46.09201839408127
fig.update_xaxes(range=[min_lon, max_lon])
fig.update_yaxes(range=[min_lat, max_lat])
return fig
if y_pred is None:
with tf.device(tf.test.gpu_device_name()):
y_pred = model.predict(x_test, verbose=0)
y_pred_o = y_pred # preserve the result
x_test = denormalize_x(x_test, x_mean, x_std, x_min, x_max)
y_pred = denormalize_y(y_pred_o, y_mean, y_std, y_min, y_max)
for sample_index in [1000, 2500, 5000, 7500]:
display(plot_trajectory(x_test, y_test[:, 1:], y_pred, sample_index))
# The metrics require a lower dimension (no impact on the results)
y_test_reshaped = np.reshape(y_test[:, 1:], (-1, y_test.shape[2]))
y_pred_reshaped = np.reshape(y_pred, (-1, y_pred.shape[2]))
# Physical distance error given in meters
all_min_error, all_max_error, all_mean_error, all_median_error = all_trajectory_error(y_test, y_pred)
print("\nAll Trajectories Min DE: {:.4f}m".format(all_min_error))
print("All Trajectories Max DE: {:.4f}m".format(all_max_error))
print("All Trajectories Mean DE: {:.4f}m".format(all_mean_error))
print("All Trajectories Median DE: {:.4f}m".format(all_median_error))
r2 = r2_score(y_test_reshaped, y_pred_reshaped)
mse = mean_squared_error(y_test_reshaped, y_pred_reshaped)
mae = mean_absolute_error(y_test_reshaped, y_pred_reshaped)
evs = explained_variance_score(y_test_reshaped, y_pred_reshaped)
mape = mean_absolute_percentage_error(y_test_reshaped, y_pred_reshaped)
rmse = np.sqrt(mse)
print(f"\nTest R^2: {r2:.4f}")
print(f"Test MAE: {mae:.4f}")
print(f"Test MSE: {mse:.4f}")
print(f"Test RMSE: {rmse:.4f}")
print(f"Test MAPE: {mape:.4f}")
print(f"Test Explained Variance Score: {evs:.4f}")
return y_pred_o
_ = evaluate_model(model, x_test, y_test, y_mean, y_std, y_min, y_max)
Model: "Training"
__________________________________________________________________________________________________
Layer (type) Output Shape Param # Connected to
==================================================================================================
Encoder_Input (InputLayer) [(None, 48, 8)] 0 []
Encoder (GRU) (None, 64) 14208 ['Encoder_Input[0][0]']
Repeater (RepeatVector) (None, 95, 64) 0 ['Encoder[0][0]']
Decoder (GRU) (None, 95, 64) 24960 ['Repeater[0][0]',
'Encoder[0][0]']
Output_Adjust (TimeDistributed (None, 95, 2) 130 ['Decoder[0][0]',
) 'Decoder-TF[0][0]']
Decoder-GT-Input (InputLayer) [(None, 95, 2)] 0 []
Mixing_Probability (InputLayer [(None, 1)] 0 []
)
Probabilistic_Teacher_Forcing (None, 95, 2) 0 ['Decoder-GT-Input[0][0]',
(ProbabilisticTeacherForcing) 'Output_Adjust[0][0]',
'Mixing_Probability[0][0]']
Decoder-TF (GRU) (None, 95, 64) 13056 ['Probabilistic_Teacher_Forcing[0
][0]',
'Encoder[0][0]']
==================================================================================================
Total params: 52,354
Trainable params: 52,354
Non-trainable params: 0
__________________________________________________________________________________________________
Model: "Inference"
__________________________________________________________________________________________________
Layer (type) Output Shape Param # Connected to
==================================================================================================
Encoder_Input (InputLayer) [(None, 48, 8)] 0 []
Encoder (GRU) (None, 64) 14208 ['Encoder_Input[0][0]']
Repeater (RepeatVector) (None, 95, 64) 0 ['Encoder[0][0]']
Decoder (GRU) (None, 95, 64) 24960 ['Repeater[0][0]',
'Encoder[0][0]']
Output_Adjust (TimeDistributed (None, 95, 2) 130 ['Decoder[0][0]']
)
==================================================================================================
Total params: 39,298
Trainable params: 39,298
Non-trainable params: 0
__________________________________________________________________________________________________A physics-informed Seq2Seq model that penalizes unrealistic velocity and acceleration in predicted vessel tracks, so forecasts stay kinematically plausible even when AIS data is sparse or noisy.
Purely statistical Seq2Seq models can forecast vessel tracks that look plausible yet turn or accelerate in ways no real ship can. This tutorial builds NPINN, an attention-based Seq2Seq LSTM trained under a combined data-fidelity and kinematic-smoothness loss, so predicted trajectories respect how vessels actually move. You walk away with a complete pipeline, from AISdb query and cleaning through training to an evaluation that reports errors in real meters on a held-out time window.
Cleaning, linking, and interpolating AIS tracks into fixed-interval UTM features with AISdb
Building sliding-window Seq2Seq datasets that predict future position residuals
Training an attention LSTM with a physics-inspired penalty on velocity and acceleration
Evaluating forecasts in meters with haversine errors and trajectory-length checks
The run shown on this page trained on a private regional database built from AISdb's Meridian receiver network, covering the Gulf of St. Lawrence and Cabot Strait over a few days in August 2018, with a test window that starts after training ends so the model must extrapolate to traffic it never saw. If you lack a comparable database, the open NOAA day file works as a drop-in. Download AIS_2020_01_01.zip from , decode the extracted CSV with aisdb.decode_msgs(filepaths=[...], dbconn=dbconn, source="NOAA") into a SQLite database, point DB_CONNECTION at it, set the bounding box to -98, 24, -80, 31, and set the dates to 2020-01-01 through 2020-01-02. One day cannot supply a genuinely unseen test window, so treat a NOAA run as a pipeline check. The numbers and figures below come from the private multi-day dataset.
Everything the pipeline needs lands in one place, including the training window, the later held-out test window, the bounding box, and the UTM projection that converts lat/lon into meters for the physics loss.
Raw AIS is noisy and irregularly sampled, so AISdb first drops near-stationary pings, links segments by great-circle score, and interpolates to fixed 5-minute steps. The function then keeps vessels whose tracks all have at least 100 pings, projects positions to UTM meters, encodes course as sine and cosine to avoid the 359-to-0 degree jump, computes per-step deltas, and scales everything with outlier-resistant RobustScalers.
The training query fits fresh scalers while the test query reuses them, keeping both windows on the same scale. A window of 80 past steps predicts the next 2 residual movements, vessels are split between training and validation by MMSI so no vessel leaks across the split, and everything is serialized once so later sessions skip the query and preprocessing.
An encoder LSTM summarizes the 80 past steps, and a decoder LSTMCell emits one residual at a time while an attention head picks which parts of the history matter for each step. Predicted residuals accumulate onto the last observed position to rebuild absolute coordinates, with teacher forcing on half the steps during training.
The physics enters through the loss (the idea traces back to physics-informed neural networks, ). First differences of the predicted path approximate velocity and second differences approximate acceleration, so penalizing both keeps trajectories kinematically smooth, while a smooth L1 term keeps them close to the observed positions. The smoothness weight decays over the first 30 epochs, letting the constraint guide early training without capping final accuracy.
Fixed seeds and deterministic CuDNN make the run reproducible, and the global min and max of the training coordinates normalize the smoothness penalty so its magnitude does not depend on where the region sits in UTM space.
A fresh session rebuilds everything from disk, the tensors, the scalers, the projection, and the best weights. The helpers invert the scaled residuals and positions back to meters, and haversine distances turn predicted lon/lat into errors a navigator can read.
For each test batch the model predicts scaled residuals, which are inverted to meters, accumulated onto the last observed position, and projected back to lon/lat. Per-timestep haversine errors and trajectory-length differences summarize accuracy, and each true versus predicted pair is drawn on a map.
Calling evaluate_with_errors on the held-out August 2018 window walks the test set trajectory by trajectory, printing the true and predicted length in meters for each one and rendering a map of the two paths.
Trajectory 2 covers 521.39 m in the ground truth and 508.66 m in the prediction, a difference of 12.73 m. In the figure below, the predicted path tracks the true path closely and stays roughly parallel to it, offset by a small, consistent margin rather than drifting away over time. The model underestimates the total path length by about 2.4%, a small error, and the smoothness of the predicted line is exactly what the kinematic penalty in the loss is meant to produce.
Trajectory 5 runs the other way. The true length is 188.76 m against a predicted 206.01 m, overestimating the distance by about 9%. The path is still smooth and follows the right general direction, but the larger relative error on a shorter trajectory shows the model has more trouble scaling step lengths correctly when there is less distance for it to work with.
Aggregated across the full test set, the mean per-timestep error is 45.03 m at the first predicted step and climbs to 80.80 m at the second, the usual compounding pattern for a residual model. Over the full two-step horizon the mean error is 62.92 m and the median is 61.72 m, close enough that a handful of outlier trajectories is not dragging the distribution around. Trajectory length holds up better than pointwise position, with a mean length difference of 11.82 m and a median of 12.73 m, under 3% relative error for most trajectories. Errors grow smoothly with the horizon rather than in erratic jumps, which is the signature the NPINN penalty is supposed to leave behind, though the two-step horizon tested here is short enough that longer horizons and a larger test population deserve their own validation.
The kinematic penalty is cheap to add and leaves a visible signature. Errors grow smoothly with the horizon, 45.03 m at the first step and 80.80 m at the second, instead of jumping erratically.
Fit scalers on the training window only, reuse them on the test window, and invert them before reporting, so every error reads in meters rather than scaled units.
The pipeline assumes one UTM zone (zone 20 here) and dense reporters (a 100-ping minimum), so swap the projection and revalidate before trusting it on wide regions, sparse vessels, or horizons past two steps.
Next, models traffic as a temporal graph, so the model learns from the relational structure of traffic rather than one ship at a time.
Discretize AIS trajectories onto an H3 grid and train a t2vec-style encoder-decoder in PyTorch that embeds vessel tracks as fixed-length vectors.
Inspired by word2vec, traj2vec treats a vessel trajectory the way a language model treats a sentence. This tutorial discretizes raw AIS tracks onto an H3 hexagon grid so each trajectory becomes a sequence of spatial tokens, then trains a PyTorch encoder-decoder to predict the next cell, which yields a fixed-length embedding for every track as a byproduct. Trajectories that look alike end up close together in embedding space, ready for clustering, similarity search, or anomaly detection downstream.
Cleaning, segmenting, and interpolating raw AIS tracks with AISdb
Tokenizing trajectories into H3 cell sequences and building a vocabulary with special tokens
Writing NLP-style .src/.trg datasets with a train/validation/test split
Training a t2vec encoder-decoder with generative and triplet losses
Reading perplexity as a sanity check on next-cell prediction
The run captured on this page pulls a month of Gulf of St. Lawrence and Nova Scotia coastal traffic (January 2023) from a private PostgreSQL database, so the figures and metrics below are not reproducible without it. The pipeline itself runs against any AISdb database. For open data, download the NOAA day file for 2020-01-01 from , decode the unzipped CSV with decode_msgs(..., source='NOAA') into a SQLiteDBConn, swap that connection for the PostgresDBConn below, and query the Gulf of Mexico window (xmin=-98, xmax=-80, ymin=24, ymax=31) for 2020-01-01 to 2020-01-02. Expect a smaller vocabulary and far fewer surviving tracks, a correctness check rather than a reproduction of the perplexity at the bottom of the page. The encoder-decoder itself comes from the t2vec reference implementation (); clone it and work from the repository root so model/, data_loader.py, and utils.py are importable.
Raw AIS is noisy, so process_interval pulls a bounding-box query, drops inland and noisy points, splits tracks on three-hour gaps, filters implausible jumps and near-stationary segments, and interpolates everything to one-minute steps so each surviving segment is a clean, continuous trajectory.
Each position maps to an H3 cell at resolution 6, and consecutive duplicates collapse into one token that keeps its entry timestamp, turning every trajectory into a sequence of discrete spatial tokens. The study region's bounding box comes from a hexagon-grid shapefile; on the NOAA drop-in, set the four bounds directly.
Very short tracks carry little sequential structure and very long ones tend to be artifacts (a vessel that never left port, a corrupted timestamp), so look at the length distribution before training and keep tracks between 10 and 300 cells.
Exactly as in NLP, every unique cell gets an integer index with three reserved for padding, start, and end of sequence; each track is then mapped to its integer sequence, plus lat/lon pairs recovered from the cells for later visualization.
The t2vec loader reads aligned text files, .src holds each sequence minus its last token, .trg the same sequence minus its first, .lat/.lon the coordinates, and _trj.t the full sequence, so we split 60/20/20 and write one line per trajectory.
Training combines two objectives, a generative next-cell loss (negative log-likelihood, exactly next-word prediction in NLP) and a discriminative triplet margin loss that pulls embeddings of similar trajectories together and pushes different ones apart. The loop validates and checkpoints every save_freq iterations, a plateau scheduler decays the learning rate, and early stopping ends the run when validation stops improving. The upstream t2vec repo also ships a KL-divergence loss weighted by inter-cell distance; plain NLL is enough here.
The hyperparameters mirror the t2vec defaults scaled down to this corpus, with the vocabulary size taken from the mapping built in Step 4.
The cumulative generative loss climbs across test batches simply because it is a running sum, not a per-batch average, so read only the final row. Normalized per token, the average loss came out to 0.2309, a perplexity of roughly 1.26.
A perplexity near 1 means the model is nearly certain about the next cell, which makes sense given how constrained vessel movement is by geography, channels, and traffic separation schemes. Treat it as a sanity check that the preprocessing, vocabulary, and encoder-decoder are wired together correctly, not as a benchmark of embedding quality. Nothing here has yet pulled the encoder's hidden state out and used it for similarity search, route clustering, or anomaly flagging, and that evaluation on the embedding vectors themselves is the natural next step.
H3 resolution is the main design choice. Resolution 6 cells span kilometers, so tight maneuvering collapses into a few tokens, while finer resolutions multiply the vocabulary and need more repeated visits per cell to learn from.
A perplexity of 1.26 on held-out tracks confirms the pipeline works, largely because shipping lanes make the next cell highly predictable.
The embeddings are the encoder's hidden states, and clustering, similarity search, or anomaly detection on them is where the model pays off.
One NOAA day validates the wiring end to end but will not reproduce month-scale metrics.
Next, returns to continuous coordinate forecasting and adds physics-informed constraints so predicted tracks respect vessel kinematics.
pip install aisdb torch scikit-learn pyproj joblib pandas cartopy matplotlibimport json
import random
from collections import defaultdict
from datetime import datetime, timedelta
import joblib
import numpy as np
import pandas as pd
import pyproj
import torch
import torch.nn as nn
import torch.nn.functional as F
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import RobustScaler
from torch.utils.data import DataLoader, TensorDataset
import aisdb
from aisdb import SQLiteDBConn, TrackGen
from aisdb.database.sqlfcn_callbacks import in_bbox_time
DB_CONNECTION = "/home/sqlite_database_file.db" # replace with your data path
START_DATE = datetime(2018, 8, 1, hour=0)
END_DATE = datetime(2018, 8, 4, hour=2)
TEST_START_DATE = datetime(2018, 8, 5, hour=0)
TEST_END_DATE = datetime(2018, 8, 6, hour=8)
XMIN, YMIN, XMAX, YMAX = -64.828126, 46.113933, -58.500001, 49.619290
dbconn = SQLiteDBConn(dbpath=DB_CONNECTION)
# Cartesian projection used to convert lat/lon into meters for the physics loss
proj = pyproj.Proj(proj='utm', zone=20, ellps='WGS84')def preprocess_aisdb_tracks(tracks_gen, proj, sog_scaler=None,
feature_scaler=None, delta_scaler=None,
fit_scaler=False):
tracks_gen = aisdb.remove_pings_wrt_speed(tracks_gen, 0.1)
tracks_gen = aisdb.encode_greatcircledistance(
tracks_gen, distance_threshold=50000, minscore=1e-5, speed_threshold=50)
tracks_gen = aisdb.interp_time(tracks_gen, step=timedelta(minutes=5))
tracks = list(tracks_gen)
tracks_by_mmsi = defaultdict(list)
for track in tracks:
tracks_by_mmsi[track['mmsi']].append(track)
# Keep only vessels whose tracks are all long enough for stable windows.
valid_tracks = []
for mmsi, mmsi_tracks in tracks_by_mmsi.items():
if all(len(t['time']) >= 100 for t in mmsi_tracks):
valid_tracks.extend(mmsi_tracks)
rows = []
for track in valid_tracks:
sog = track.get('sog', [np.nan]*len(track['time']))
cog = track.get('cog', [np.nan]*len(track['time']))
for i in range(len(track['time'])):
x, y = proj(track['lon'][i], track['lat'][i])
cog_rad = np.radians(cog[i]) if cog[i] is not None else np.nan
rows.append({
'mmsi': track['mmsi'], 'x': x, 'y': y, 'sog': sog[i],
'cog_sin': np.sin(cog_rad) if not np.isnan(cog_rad) else np.nan,
'cog_cos': np.cos(cog_rad) if not np.isnan(cog_rad) else np.nan,
'timestamp': pd.to_datetime(track['time'][i], unit='s', errors='coerce')
})
df = pd.DataFrame(rows).replace([np.inf, -np.inf], np.nan)
df = df.dropna(subset=['x', 'y', 'sog', 'cog_sin', 'cog_cos'])
# Per-vessel deltas capture local motion dynamics.
df = df.sort_values(["mmsi", "timestamp"])
df["dx"] = df.groupby("mmsi")["x"].diff().fillna(0)
df["dy"] = df.groupby("mmsi")["y"].diff().fillna(0)
feature_cols = ['x', 'y', 'sog', 'cog_sin', 'cog_cos']
delta_cols = ['dx', 'dy']
if fit_scaler:
sog_scaler = RobustScaler()
df['sog_scaled'] = sog_scaler.fit_transform(df[['sog']])
feature_scaler = RobustScaler()
df[feature_cols] = feature_scaler.fit_transform(df[feature_cols])
delta_scaler = RobustScaler()
df[delta_cols] = delta_scaler.fit_transform(df[delta_cols])
else:
df['sog_scaled'] = sog_scaler.transform(df[['sog']])
df[feature_cols] = feature_scaler.transform(df[feature_cols])
df[delta_cols] = delta_scaler.transform(df[delta_cols])
return df, sog_scaler, feature_scaler, delta_scalerdef window_tracks(start, end):
qry = aisdb.DBQuery(dbconn=dbconn, callback=in_bbox_time, start=start, end=end,
xmin=XMIN, xmax=XMAX, ymin=YMIN, ymax=YMAX)
return TrackGen(qry.gen_qry(verbose=True), decimate=False)
train_df, sog_scaler, feature_scaler, delta_scaler = preprocess_aisdb_tracks(
window_tracks(START_DATE, END_DATE), proj, fit_scaler=True)
test_df, _, _, _ = preprocess_aisdb_tracks(
window_tracks(TEST_START_DATE, TEST_END_DATE), proj, sog_scaler=sog_scaler,
feature_scaler=feature_scaler, delta_scaler=delta_scaler, fit_scaler=False)
def create_sequences(df, features, input_size=80, output_size=2, step=1):
# X holds past windows of absolute features, Y the future (dx, dy) residuals.
X_list, Y_list = [], []
for mmsi in df['mmsi'].unique():
sub = df[df['mmsi'] == mmsi].sort_values('timestamp').copy()
feat_arr = sub[features].to_numpy()
dxdy_arr = sub[['dx', 'dy']].to_numpy() # residuals already scaled
for i in range(0, len(sub) - input_size - output_size + 1, step):
X_list.append(feat_arr[i : i + input_size])
Y_list.append(dxdy_arr[i + input_size : i + input_size + output_size])
return torch.tensor(X_list, dtype=torch.float32), torch.tensor(Y_list, dtype=torch.float32)
features = ['x', 'y', 'dx', 'dy', 'cog_sin', 'cog_cos', 'sog_scaled']
mmsis = train_df['mmsi'].unique()
train_mmsi, val_mmsi = train_test_split(mmsis, test_size=0.2, random_state=42, shuffle=True)
train_X, train_Y = create_sequences(train_df[train_df['mmsi'].isin(train_mmsi)], features)
val_X, val_Y = create_sequences(train_df[train_df['mmsi'].isin(val_mmsi)], features)
test_X, test_Y = create_sequences(test_df, features)
torch.save({'train_X': train_X, 'train_Y': train_Y, 'val_X': val_X,
'val_Y': val_Y, 'test_X': test_X, 'test_Y': test_Y}, 'datasets_npin.pt')
joblib.dump(feature_scaler, "npinn_feature_scaler.pkl")
joblib.dump(sog_scaler, "npinn_sog_scaler.pkl")
joblib.dump(delta_scaler, "npinn_delta_scaler.pkl")
with open("npinn_proj_params.json", "w") as f:
json.dump({'proj': 'utm', 'zone': 20, 'ellps': 'WGS84'}, f)
batch_size = 64
train_dl = DataLoader(TensorDataset(train_X, train_Y), batch_size=batch_size, shuffle=True)
val_dl = DataLoader(TensorDataset(val_X, val_Y), batch_size=batch_size, shuffle=False)
test_dl = DataLoader(TensorDataset(test_X, test_Y), batch_size=batch_size)class Seq2SeqLSTM(nn.Module):
def __init__(self, input_size, hidden_size, input_steps, output_steps):
super().__init__()
self.input_steps = input_steps
self.output_steps = output_steps
self.encoder = nn.LSTM(input_size, hidden_size, num_layers=2, dropout=0.3, batch_first=True)
self.decoder = nn.LSTMCell(input_size, hidden_size)
self.attn = nn.Linear(hidden_size * 2, input_steps)
self.attn_combine = nn.Linear(hidden_size + input_size, input_size)
# Output only x,y residuals (added to last observed pos)
self.output_layer = nn.Sequential(
nn.Linear(hidden_size, hidden_size // 2),
nn.ReLU(),
nn.Linear(hidden_size // 2, 2)
)
def forward(self, x, target_seq=None, teacher_forcing_ratio=0.5):
encoder_outputs, (h, c) = self.encoder(x)
h, c = h[-1], c[-1]
last_obs = x[:, -1, :2] # last observed absolute x,y
decoder_input = x[:, -1, :] # full feature vector
outputs = []
for t in range(self.output_steps):
attn_weights = torch.softmax(self.attn(torch.cat((h, c), dim=1)), dim=1)
context = torch.bmm(attn_weights.unsqueeze(1), encoder_outputs).squeeze(1)
dec_in = self.attn_combine(torch.cat((decoder_input, context), dim=1))
h, c = self.decoder(dec_in, (h, c))
residual_xy = self.output_layer(h)
out_xy = residual_xy + last_obs
outputs.append(out_xy.unsqueeze(1))
if self.training and target_seq is not None and t < target_seq.size(1) and random.random() < teacher_forcing_ratio:
decoder_input = torch.cat([target_seq[:, t, :2], decoder_input[:, 2:]], dim=1)
last_obs = target_seq[:, t, :2]
else:
decoder_input = torch.cat([out_xy, decoder_input[:, 2:]], dim=1)
last_obs = out_xy
return torch.cat(outputs, dim=1) # (batch, output_steps, 2)def weighted_coord_loss(pred, target, coord_weight=5.0, reduction='mean'):
return F.smooth_l1_loss(pred, target, reduction=reduction)
def xy_npinn_smoothness_loss(seq_full, coord_min=None, coord_max=None):
# NPINN-inspired smoothness penalty on xy coordinates; seq_full: [B, T, 2]
xy = seq_full[..., :2]
if coord_min is not None and coord_max is not None:
xy_norm = (xy - coord_min) / (coord_max - coord_min + 1e-8)
xy_norm = 2 * (xy_norm - 0.5) # [-1,1]
else:
xy_norm = xy
v = xy_norm[:, 1:, :] - xy_norm[:, :-1, :] # velocity proxy
a = v[:, 1:, :] - v[:, :-1, :] # acceleration proxy
return (v**2).mean() * 0.05 + (a**2).mean() * 0.5
def train_model(model, loader, val_dl, optimizer, device, epochs=50,
smooth_w_init=1e-3, coord_min=None, coord_max=None):
best_loss = float('inf')
best_state = None
for epoch in range(epochs):
model.train()
total_loss = total_data_loss = total_smooth_loss = 0.0
for batch_x, batch_y in loader:
batch_x, batch_y = batch_x.to(device), batch_y.to(device)
optimizer.zero_grad()
pred_xy = model(batch_x, target_seq=batch_y, teacher_forcing_ratio=0.5)
loss_data = weighted_coord_loss(pred_xy, batch_y)
full_seq = torch.cat([batch_x[:, :, :2], pred_xy], dim=1) # observed + predicted
loss_smooth = xy_npinn_smoothness_loss(full_seq, coord_min, coord_max)
smooth_weight = smooth_w_init * max(0.1, 1.0 - epoch / 30.0)
loss = loss_data + smooth_weight * loss_smooth
loss.backward()
optimizer.step()
total_loss += loss.item()
total_data_loss += loss_data.item()
total_smooth_loss += loss_smooth.item()
avg_loss = total_loss / len(loader)
print(f"Epoch {epoch+1} | Total: {avg_loss:.6f} | Data: {total_data_loss/len(loader):.6f} | Smooth: {total_smooth_loss/len(loader):.6f}")
model.eval()
val_loss = 0.0
with torch.no_grad():
for xb, yb in val_dl:
xb, yb = xb.to(device), yb.to(device)
pred_xy = model(xb, target_seq=yb, teacher_forcing_ratio=0.0)
data_loss = weighted_coord_loss(pred_xy, yb)
full_seq = torch.cat([xb[..., :2], pred_xy], dim=1)
loss_smooth = xy_npinn_smoothness_loss(full_seq, coord_min, coord_max)
val_loss += (data_loss + smooth_weight * loss_smooth).item()
val_loss /= len(val_dl)
print(f" Val Loss: {val_loss:.6f}")
if val_loss < best_loss:
best_loss = val_loss
best_state = model.state_dict()
if best_state is not None:
torch.save(best_state, "best_model_NPINN.pth")
print("Best model saved")SEED = 42
torch.manual_seed(SEED)
np.random.seed(SEED)
random.seed(SEED)
torch.cuda.manual_seed_all(SEED)
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# Global xy min/max from the training set, for smoothness-loss normalization.
flat_train_X = train_X.view(-1, train_X.shape[-1]) # [N*T, F]
coord_min = torch.tensor([flat_train_X[:, 0].min().item(), flat_train_X[:, 1].min().item()], device=device)
coord_max = torch.tensor([flat_train_X[:, 0].max().item(), flat_train_X[:, 1].max().item()], device=device)
input_size, hidden_size = 7, 64
input_steps, output_steps = 80, 2
model = Seq2SeqLSTM(input_size, hidden_size, input_steps, output_steps).to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
train_model(model, train_dl, val_dl, optimizer, device,
coord_min=coord_min, coord_max=coord_max)device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
data = torch.load('datasets_npin.pt')
train_X, train_Y = data['train_X'], data['train_Y']
test_X, test_Y = data['test_X'], data['test_Y']
test_dl = DataLoader(TensorDataset(test_X, test_Y), batch_size=64)
feature_scaler = joblib.load("npinn_feature_scaler.pkl")
delta_scaler = joblib.load("npinn_delta_scaler.pkl")
with open("npinn_proj_params.json", "r") as f:
proj = pyproj.Proj(**json.load(f))
best_model = Seq2SeqLSTM(input_size=train_X.shape[2], hidden_size=64,
input_steps=train_X.shape[1], output_steps=train_Y.shape[1]).to(device)
best_model.load_state_dict(torch.load("best_model_NPINN.pth", map_location=device))
best_model.eval()
def inverse_dxdy_np(dxdy_scaled, scaler):
"""Invert scaled (dx, dy) residuals back to meters."""
dxdy_scaled = np.asarray(dxdy_scaled, dtype=float)
if dxdy_scaled.ndim == 1:
dxdy_scaled = dxdy_scaled[None, :]
full_scaled = np.zeros((dxdy_scaled.shape[0], scaler.scale_.shape[0]))
full_scaled[:, :2] = dxdy_scaled
full = full_scaled * scaler.scale_ + scaler.center_
return full[:, :2] if dxdy_scaled.shape[0] > 1 else full[0, :2]
def inverse_xy_only_np(xy_scaled, scaler):
"""Invert scaled x, y (first two columns of feature_scaler) to meters."""
return xy_scaled * scaler.scale_[:2] + scaler.center_[:2]
def haversine(lon1, lat1, lon2, lat2):
"""Distance (m) between lon/lat points; handles arrays."""
R = 6371000.0
lon1, lat1, lon2, lat2 = map(np.radians, map(np.asarray, [lon1, lat1, lon2, lat2]))
dlon, dlat = lon2 - lon1, lat2 - lat1
a = np.sin(dlat/2.0)**2 + np.cos(lat1)*np.cos(lat2)*np.sin(dlon/2.0)**2
return 2 * R * np.arcsin(np.sqrt(np.clip(a, 0.0, 1.0)))
def trajectory_length(lons, lats):
lons, lats = np.asarray(lons, dtype=float), np.asarray(lats, dtype=float)
if lons.size < 2 or not (np.isfinite(lons).all() and np.isfinite(lats).all()):
return 0.0 if lons.size < 2 else float("nan")
return np.sum(haversine(lons[:-1], lats[:-1], lons[1:], lats[1:]))import matplotlib.pyplot as plt
import cartopy.crs as ccrs
import cartopy.feature as cfeature
def evaluate_with_errors(model, test_dl, proj, feature_scaler, delta_scaler,
device, dup_tol=1e-4, plot_map=True):
"""Evaluate trajectory predictions, report errors in meters, optionally plot."""
model.eval()
errors_all, length_diffs = [], []
all_real, all_pred = [], []
with torch.no_grad():
for xb, yb in test_dl:
xb, yb = xb.to(device), yb.to(device)
pred = model(xb, teacher_forcing_ratio=0.0) # [B, T_out, 2]
# First sample of the batch.
input_seq = xb[0].cpu().numpy()
real_seq = yb[0].cpu().numpy()
pred_seq = pred[0].cpu().numpy()
# Invert residuals to meters, then accumulate from the last observed position.
pred_resid_m = inverse_dxdy_np(pred_seq[:, :2], delta_scaler)
real_resid_m = inverse_dxdy_np(real_seq[:, :2], delta_scaler)
last_obs_xy_m = inverse_xy_only_np(input_seq[-1, :2], feature_scaler)
pred_xy_m = np.cumsum(pred_resid_m, axis=0) + last_obs_xy_m
real_xy_m = np.cumsum(real_resid_m, axis=0) + last_obs_xy_m
# Remove first target if it duplicates the last input, then align.
if np.allclose(real_resid_m[0], 0, atol=dup_tol):
real_xy_m, pred_xy_m = real_xy_m[1:], pred_xy_m[1:]
min_len = min(len(pred_xy_m), len(real_xy_m))
pred_xy_m, real_xy_m = pred_xy_m[:min_len], real_xy_m[:min_len]
lon_real, lat_real = proj(real_xy_m[:, 0], real_xy_m[:, 1], inverse=True)
lon_pred, lat_pred = proj(pred_xy_m[:, 0], pred_xy_m[:, 1], inverse=True)
all_real.append((lon_real, lat_real))
all_pred.append((lon_pred, lat_pred))
errors_all.append(haversine(lon_real, lat_real, lon_pred, lat_pred))
real_len = trajectory_length(lon_real, lat_real)
pred_len = trajectory_length(lon_pred, lat_pred)
length_diffs.append(abs(real_len - pred_len))
print(f"Trajectory length (true): {real_len:.2f} m | pred: {pred_len:.2f} m | diff: {abs(real_len - pred_len):.2f} m")
# Pad to a common horizon, then summarize.
max_len = max(len(e) for e in errors_all)
errors_padded = np.full((len(errors_all), max_len), np.nan)
for i, e in enumerate(errors_all):
errors_padded[i, :len(e)] = e
mean_per_t = np.nanmean(errors_padded, axis=0)
print("\n=== Summary (meters) ===")
for t, v in enumerate(mean_per_t):
if not np.isnan(v):
print(f"t={t} mean error: {v:.2f} m")
print(f"Mean over horizon: {np.nanmean(errors_padded):.2f} m | Median: {np.nanmedian(errors_padded):.2f} m")
print(f"Mean trajectory length diff: {np.mean(length_diffs):.2f} m | Median: {np.median(length_diffs):.2f} m")
if plot_map:
for idx, ((lon_r, lat_r), (lon_p, lat_p)) in enumerate(zip(all_real, all_pred)):
plt.figure(figsize=(10, 8))
ax = plt.axes(projection=ccrs.PlateCarree())
ax.add_feature(cfeature.LAND)
ax.add_feature(cfeature.COASTLINE)
ax.add_feature(cfeature.BORDERS, linestyle=':')
ax.plot(lon_r, lat_r, color='green', linewidth=2, label="True")
ax.plot(lon_p, lat_p, color='red', linestyle='--', linewidth=2, label="Predicted")
ax.legend()
ax.set_title(f"Trajectory {idx+1}: True vs Predicted")
plt.show()
evaluate_with_errors(best_model, test_dl, proj, feature_scaler, delta_scaler,
device, plot_map=True)Trajectory length (true): 521.39 m | pred: 508.66 m | diff: 12.73 mTrajectory length (true): 188.76 m | pred: 206.01 m | diff: 17.25 mpip install aisdb torch h3 geopandas cartopy matplotlib seaborn scikit-learn tqdm nest_asyncioimport os
import json
import h3
import aisdb
import numpy as np
import geopandas as gpd
import matplotlib.pyplot as plt
import cartopy.crs as ccrs
import cartopy.feature as cfeature
from aisdb.database.dbconn import PostgresDBConn
from aisdb.denoising_encoder import encode_greatcircledistance, InlandDenoising
from aisdb.track_gen import min_speed_filter
from aisdb.database import sqlfcn
from datetime import datetime, timedelta
from tqdm import tqdm
import nest_asyncio
nest_asyncio.apply()
dbconn = PostgresDBConn(hostaddr='127.0.0.1', port=5432, user='postgres',
password=os.environ.get('POSTGRES_PASSWORD'), dbname='postgres')
def process_interval(dbconn, start, end):
qry = aisdb.DBQuery(dbconn=dbconn, start=start, end=end,
xmin=xmin, ymin=ymin, xmax=xmax, ymax=ymax,
callback=aisdb.database.sqlfcn_callbacks.in_bbox_time_validmmsi)
# decimate=False keeps every reported point instead of curve-decimating them
rowgen = qry.gen_qry(fcn=sqlfcn.crawl_dynamic_static)
tracks = aisdb.track_gen.TrackGen(rowgen, decimate=False)
with InlandDenoising(data_dir='./data/tmp/') as remover:
cleaned_tracks = remover.filter_noisy_points(tracks)
# Split on time gaps, drop implausible segments, interpolate every minute.
track_segments = aisdb.track_gen.split_timedelta(cleaned_tracks, time_split)
tracks_encoded = encode_greatcircledistance(track_segments, distance_threshold=distance_split, speed_threshold=speed_split)
tracks_encoded = min_speed_filter(tracks_encoded, minspeed=1)
tracks_interpolated = aisdb.interp.interp_time(tracks_encoded, step=timedelta(minutes=1))
return list(tracks_interpolated)gdf_hexagons = gpd.read_file('./data/cell/Hexagons_6.shp').to_crs(epsg=4326)
xmin, ymin, xmax, ymax = gdf_hexagons.total_bounds
start_date, end_date = datetime(2023, 1, 1), datetime(2023, 1, 30)
time_split = timedelta(hours=3)
distance_split, speed_split = 10000, 40 # meters, knots
g2h3_vec = np.vectorize(h3.latlng_to_cell)
track_info_list = []
track_list = process_interval(dbconn, start_date, end_date)
for track in tqdm(track_list, total=len(track_list), desc="Vessels", leave=False):
h3_ids = g2h3_vec(track['lat'], track['lon'], 6)
timestamps = track['time']
# Deduplicate consecutive identical cells, keeping the entry timestamp.
dedup_h3_ids = [h3_ids[0]]
dedup_timestamps = [timestamps[0]]
for i in range(1, len(h3_ids)):
if h3_ids[i] != dedup_h3_ids[-1]:
dedup_h3_ids.append(h3_ids[i])
dedup_timestamps.append(timestamps[i])
track_info_list.append({"mmsi": track['mmsi'], "h3_seq": dedup_h3_ids,
"timestamp_seq": dedup_timestamps})import seaborn as sns
def plot_length_distribution(track_lengths):
plt.figure(figsize=(10, 6))
sns.histplot(track_lengths, bins=100, kde=True)
plt.title("Distribution of Track Lengths")
plt.xlabel("Track Length (number of H3 cells)")
plt.ylabel("Frequency")
plt.show()
def map_view(tracks, color=None, line_width=0.5, line_opacity=0.3):
plt.figure(figsize=(16, 9))
ax = plt.axes(projection=ccrs.PlateCarree())
ax.add_feature(cfeature.OCEAN.with_scale('10m'), facecolor='#E0E0E0')
ax.add_feature(cfeature.LAND.with_scale('10m'), facecolor='#FFE5CC')
ax.coastlines(resolution='10m')
for track in tqdm(tracks):
ax.plot(track['lon'], track['lat'], color=color, linewidth=line_width,
alpha=line_opacity, transform=ccrs.PlateCarree())
ax.gridlines(draw_labels=True)
plt.show()
def hex_view(lats, lons):
plt.figure(figsize=(8, 8))
for traj_lat, traj_lon in zip(lats, lons):
plt.plot(traj_lon, traj_lat, alpha=0.3, linewidth=1)
plt.xlabel("Longitude")
plt.ylabel("Latitude")
plt.title("Test Trajectories")
plt.axis("equal")
map_view(track_list)
plot_length_distribution([len(t['h3_seq']) for t in track_info_list])track_info_list = [t for t in track_info_list if (len(t['h3_seq']) >= 10) & (len(t['h3_seq']) <= 300)]
plot_length_distribution([len(t['h3_seq']) for t in track_info_list])vec_cell_to_latlng = np.vectorize(h3.cell_to_latlng)
all_h3_ids = {h for t in track_info_list for h in t['h3_seq']}
# Reserve 0, 1, 2 for <PAD>, <BOS>, <EOS>.
h3_vocab = {h: i + 3 for i, h in enumerate(sorted(all_h3_ids))}
h3_vocab.update({"<PAD>": 0, "<BOS>": 1, "<EOS>": 2})
for t in track_info_list:
t["int_seq"] = [h3_vocab[h] for h in t["h3_seq"] if h in h3_vocab]
t["lat"], t["lon"] = vec_cell_to_latlng(t.get('h3_seq'))from sklearn.model_selection import train_test_split
train_tracks, temp_tracks = train_test_split(track_info_list, test_size=0.4, random_state=42)
val_tracks, test_tracks = train_test_split(temp_tracks, test_size=0.5, random_state=42)
def save_data(tracks, prefix, output_dir="data"):
os.makedirs(output_dir, exist_ok=True)
with open(os.path.join(output_dir, f"{prefix}.src"), "w") as f_src, \
open(os.path.join(output_dir, f"{prefix}.trg"), "w") as f_trg, \
open(os.path.join(output_dir, f"{prefix}.lat"), "w") as f_lat, \
open(os.path.join(output_dir, f"{prefix}.lon"), "w") as f_lon, \
open(os.path.join(output_dir, f"{prefix}_trj.t"), "w") as f_t:
for t in tracks:
ids = t["int_seq"]
f_t.write(" ".join(map(str, ids)) + "\n")
f_src.write(" ".join(map(str, ids[:-1])) + "\n")
f_trg.write(" ".join(map(str, ids[1:])) + "\n")
f_lat.write(" ".join(map(str, t.get('lat'))) + "\n")
f_lon.write(" ".join(map(str, t.get('lon'))) + "\n")
save_data(train_tracks, "train")
save_data(val_tracks, "val")
save_data(test_tracks, "test")
with open("data/vocab.json", "w") as f:
json.dump(h3_vocab, f, indent=2) # the INT index to H3 index mapping
lats = [np.fromstring(line, sep=' ') for line in open("data/train.lat")]
lons = [np.fromstring(line, sep=' ') for line in open("data/train.lon")]
hex_view(lats, lons)import shutil
import torch
import torch.nn as nn
from torch.nn.utils import clip_grad_norm_
from model.t2vec import EncoderDecoder
from data_loader import DataLoader
from utils import *
from model.loss import *
PAD = 0
def make_loader(args, prefix, **kw):
path = lambda ext: os.path.join(args.data, prefix + ext)
return DataLoader(path(".src"), path(".trg"), path(".lat"), path(".lon"),
args.batch, args.bucketsize, **kw)
def init_parameters(model):
for p in model.parameters():
p.data.uniform_(-0.1, 0.1)
def savecheckpoint(state, is_best, args):
torch.save(state, args.checkpoint)
if is_best:
shutil.copyfile(args.checkpoint, os.path.join(args.data, 'best_model.pt'))
def validate(valData, model, lossF, args):
m0, m1 = model
m0.eval()
m1.eval()
num_iteration = (valData.size + args.batch - 1) // args.batch
total_genloss = 0
for iteration in range(num_iteration):
gendata = valData.getbatch_generative()
with torch.no_grad():
genloss = genLoss(gendata, m0, m1, lossF, args)
total_genloss += genloss.item() * gendata.trg.size(1)
m0.train()
m1.train()
return total_genloss / valData.size
def train(args):
trainData = make_loader(args, "train")
trainData.load(args.max_num_line)
valData = make_loader(args, "val", validate=True)
valData.load()
criterion = NLLcriterion(args.vocab_size)
lossF = lambda o, t: criterion(o, t)
triplet_loss = nn.TripletMarginLoss(margin=1.0, p=2)
m0 = EncoderDecoder(args.vocab_size, args.embedding_size, args.hidden_size,
args.num_layers, args.dropout, args.bidirectional)
m1 = nn.Sequential(nn.Linear(args.hidden_size, args.vocab_size),
nn.LogSoftmax(dim=1))
if args.cuda and torch.cuda.is_available():
m0.cuda()
m1.cuda()
criterion.cuda()
m0_optimizer = torch.optim.Adam(m0.parameters(), lr=args.learning_rate)
m1_optimizer = torch.optim.Adam(m1.parameters(), lr=args.learning_rate)
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
m0_optimizer, mode='min', patience=args.lr_decay_patience, min_lr=0, verbose=True)
best_prec_loss = float('inf')
init_parameters(m0)
init_parameters(m1)
num_iteration = 6700 * 128 // args.batch
no_improvement_count = 0
for iteration in range(args.start_iteration, num_iteration):
m0_optimizer.zero_grad()
m1_optimizer.zero_grad()
gendata = trainData.getbatch_generative()
genloss = genLoss(gendata, m0, m1, lossF, args)
disloss_cross, disloss_inner = 0, 0
if args.use_discriminative and iteration % 5 == 0:
a, p, n = trainData.getbatch_discriminative_cross()
disloss_cross = disLoss(a, p, n, m0, triplet_loss, args)
a, p, n = trainData.getbatch_discriminative_inner()
disloss_inner = disLoss(a, p, n, m0, triplet_loss, args)
loss = genloss + args.discriminative_w * (disloss_cross + disloss_inner)
loss.backward()
clip_grad_norm_(m0.parameters(), args.max_grad_norm)
clip_grad_norm_(m1.parameters(), args.max_grad_norm)
m0_optimizer.step()
m1_optimizer.step()
if iteration % args.print_freq == 0:
avg_genloss = genloss.item() / gendata.trg.size(0)
print(f"Iteration: {iteration}\tGenerative Loss: {avg_genloss:.3f}\t"
f"Discriminative Cross Loss: {disloss_cross:.3f}\tDiscriminative Inner Loss: {disloss_inner:.3f}")
if iteration % args.save_freq == 0 and iteration > 0:
prec_loss = validate(valData, (m0, m1), lossF, args)
scheduler.step(prec_loss)
is_best = prec_loss < best_prec_loss
if is_best:
best_prec_loss = prec_loss
no_improvement_count = 0
else:
no_improvement_count += 1
savecheckpoint({"iteration": iteration, "best_prec_loss": best_prec_loss,
"m0": m0.state_dict(), "m1": m1.state_dict(),
"m0_optimizer": m0_optimizer.state_dict(),
"m1_optimizer": m1_optimizer.state_dict()}, is_best, args)
if no_improvement_count >= args.early_stopping_patience:
print(f"No improvement after {args.early_stopping_patience} iterations, early stopping triggered.")
break
def test(args):
testData = make_loader(args, "test", validate=True)
testData.load()
m0 = EncoderDecoder(args.vocab_size, args.embedding_size, args.hidden_size,
args.num_layers, args.dropout, args.bidirectional)
m1 = nn.Sequential(nn.Linear(args.hidden_size, args.vocab_size),
nn.LogSoftmax(dim=1))
best_model = torch.load(os.path.join(args.data, 'best_model.pt'))
m0.load_state_dict(best_model["m0"])
m1.load_state_dict(best_model["m1"])
m0.eval()
m1.eval()
criterion = NLLcriterion(args.vocab_size)
lossF = lambda o, t: criterion(o, t)
if args.cuda and torch.cuda.is_available():
m0.cuda()
m1.cuda()
criterion.cuda()
num_iteration = (testData.size + args.batch - 1) // args.batch
total_genloss, total_tokens = 0, 0
with torch.no_grad():
for iter in range(num_iteration):
gendata = testData.getbatch_generative()
genloss = genLoss(gendata, m0, m1, lossF, args)
total_genloss += genloss.item()
total_tokens += (gendata.trg != PAD).sum().item() # count non-pad tokens
print("Testing genloss at {} iteration is {}".format(iter, total_genloss))
avg_loss = total_genloss / total_tokens
perplexity = torch.exp(torch.tensor(avg_loss))
print(f"[Test] Avg Loss: {avg_loss:.4f} | Perplexity: {perplexity:.2f}")class Args:
data = 'data/'
checkpoint = 'data/checkpoint.pt'
vocab_size = len(h3_vocab)
embedding_size = 128
hidden_size = 128
num_layers = 1
dropout = 0.1
max_grad_norm = 1.0
learning_rate = 1e-2
lr_decay_patience = 20
early_stopping_patience = 50
cuda = torch.cuda.is_available()
bidirectional = True
batch = 16
bucketsize = [(20,30),(30,30),(30,50),(50,50),(50,70),(70,70),(70,100),(100,100)]
use_discriminative = True
discriminative_w = 0.1
max_num_line = 200000
start_iteration = 0
generator_batch = 16
print_freq = 10
save_freq = 10
args = Args()
train(args)
test(args)





Testing genloss at 0 iteration is 46.40993881225586
Testing genloss at 1 iteration is 83.17555618286133
Testing genloss at 2 iteration is 122.76013565063477
Testing genloss at 3 iteration is 167.81907272338867
Testing genloss at 4 iteration is 223.75146102905273
Testing genloss at 5 iteration is 287.765926361084
Testing genloss at 6 iteration is 328.6252250671387
Testing genloss at 7 iteration is 394.95031356811523
Testing genloss at 8 iteration is 459.411678314209
Testing genloss at 9 iteration is 557.8198432922363
Testing genloss at 10 iteration is 724.5464973449707
Testing genloss at 11 iteration is 876.1395149230957
Testing genloss at 12 iteration is 1020.6461372375488
Testing genloss at 13 iteration is 1277.3499336242676
Testing genloss at 14 iteration is 1416.0101203918457
Testing genloss at 15 iteration is 1742.3399543762207
Testing genloss at 16 iteration is 2101.4984016418457
Testing genloss at 17 iteration is 2319.603458404541
[Test] Avg Loss: 0.2309 | Perplexity: 1.26Build an attention-based encoder-decoder LSTM in PyTorch that forecasts a vessel's next positions from its recent AIS track, using AISdb to query, clean, and reshape the trajectories for training.
This tutorial builds a sequence-to-sequence LSTM in PyTorch that forecasts where a vessel is heading next from its last 80 AIS fixes. AISdb handles the querying, cleaning, and interpolation of the trajectories, and the model predicts position residuals that decode back to lat/lon on a map. You walk away with a complete forecasting pipeline, from raw AIS messages to a metric error per predicted step.
Querying and cleaning vessel tracks with AISdb generators (DBQuery, TrackGen, denoising, interpolation)
Turning trajectories into sliding-window tensors for supervised forecasting
Building an attention-based encoder-decoder LSTM with teacher forcing
Training with a coordinate-weighted loss plus a smoothness penalty
Decoding residual predictions back to lat/lon and measuring haversine error
The run on this page trains against a private SQLite extract of AIS traffic in the Gulf of St. Lawrence, first week of August 2018, so treat DB_CONNECTION below as a stand-in for whatever regional extract you have. Without private data, the open NOAA day file works as a drop-in. Decode it with aisdb.decode_msgs(filepaths=[...], dbconn=dbconn, source='NOAA') the way shows, point DB_CONNECTION at the result, and swap in the Gulf of Mexico window (xmin=-98, xmax=-80, ymin=24, ymax=31, 2020-01-01 to 2020-01-02). Expect a noticeably worse error than the roughly 830 m reported below, since one day of data gives the sliding-window sequencer far fewer long, dense tracks to learn from.
DBQuery wraps a time range and a bounding box into a SQL query and hands back a generator of rows, which TrackGen vectorizes into per-vessel tracks. The test set comes from a later date range than the training set so the model cannot memorize it.
Raw AIS is too irregular to feed a model directly. The function below drops near-stationary pings, splits segments that do not belong to the same continuous trip, interpolates to a fixed 5-minute step, keeps only MMSIs with at least 100 points, projects lat/lon into UTM meters with pyproj, encodes course over ground as sine and cosine because it wraps at 360 degrees, and scales every feature with a RobustScaler fit on the training set only.
A sliding window turns each trajectory into supervised examples, 80 input steps predicting the next 2. Splitting train and validation by MMSI keeps every vessel on one side of the split, so validation measures generalization to unseen vessels rather than unseen windows of seen ones.
The encoder LSTM compresses the 80-step history into hidden states, and the decoder unrolls two future steps, attending over the encoder outputs at each step () and predicting a residual that is added to the last observed position. Anchoring every forecast to the vessel's current fix keeps predictions physically plausible, and teacher forcing occasionally feeds the decoder ground truth during training so multi-step errors do not compound early ().
The loss combines the coordinate-weighted data term with a smoothness penalty that decays over epochs, and the best epoch by validation loss is checkpointed to disk. Seeds are fixed so the run is reproducible.
A small decimal loss can hide predictions that land far from the real position, so the evaluation decodes the residuals back to lat/lon, measures haversine error in meters, and plots the track on a map as the sanity check. The residuals are decoded by multiplying with the per-axis standard deviation of the true training residuals in meters, then cumulative-summing from the last observed position.
The call above evaluates a single test batch (num_batches=1), decoding the residual predictions back into lat/lon and printing a predicted-vs-true table.
Predicted vs True (lat/lon)
Summary (meters) t=0 mean error: 833.31 m mean over horizon: 833.31 m, median: 833.31 m
That 833 meter figure is a single two-step window from one batch, not an average across the whole test set, so treat it as illustrative rather than a benchmark. What it does show is that the residual decoding is behaving. If the scaler or the cumulative sum step were wrong, the predicted point would land tens of kilometers off or on the wrong side of the coastline instead of a few hundred meters short of the real fix.
The predicted marker sits close to the true one and on the same heading as the input history, which is what a residual decoder anchored to the last observed position should produce. Error grows with the output horizon and with sharp maneuvers, since the model only sees the pattern in its input window and has no notion of destination or intent.
AISdb's generator chain (remove_pings_wrt_speed, encode_greatcircledistance, interp_time) turns raw AIS into model-ready tracks in a few lines.
Predicting position residuals anchored to the last fix keeps forecasts physically plausible, and decoding mistakes show up on the map immediately.
The 833 m error is one illustrative window under a single split and seed; cross-validate across time windows and vessel types before trusting the number.
Next, tackles the same forecasting problem with a GRU-based encoder-decoder and teacher forcing, this time in Keras.
Preprocess, discretize, and enrich AIS tracks through a point-and-click Gradio app, with built-in plotting and a Gemini assistant, no Python script required.
Every earlier page in this section assumes you are comfortable calling AISdb from a script. This page removes that requirement. You launch a single Gradio app that wraps the same track-generation, discretization, and enrichment functions used throughout this guide, then clean a CSV of AIS positions, add H3 grid cells, layer in bathymetry and weather, plot the result, and question it with a Gemini assistant, all from a browser tab.
Launch the AISdb no-code app and load your own AIS CSV
Chain preprocessing steps (time splits, encoding, H3 discretization, stop detection) from a checklist
43.22385
833.31 m
UTM zone 20 and the fitted scalers are specific to this region and season, so update the projection and refit the scalers when you move the pipeline elsewhere.
0
-61.69744
-61.70585


43.22816
pip install aisdb torch scikit-learn pandas numpy pyproj cartopy matplotlibimport random
from collections import defaultdict
from datetime import datetime, timedelta
import cartopy.crs as ccrs
import cartopy.feature as cfeature
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import pyproj
import torch
import torch.nn as nn
import torch.nn.functional as F
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import RobustScaler
from torch.utils.data import DataLoader, TensorDataset
import aisdb
from aisdb import DBConn
from aisdb.database.sqlfcn_callbacks import in_time_bbox
from aisdb.track_gen import TrackGen
DB_CONNECTION = "/home/sqlite_database_file.db" # replace with your data path
START_DATE = datetime(2018, 8, 1, hour=0)
END_DATE = datetime(2018, 8, 4, hour=2)
XMIN, YMIN, XMAX, YMAX = -64.828126, 46.113933, -58.500001, 49.619290
dbconn = DBConn(dbpath=DB_CONNECTION)
# in_time_bbox filters rows by both the time range and the bounding box.
train_qry = aisdb.DBQuery(dbconn=dbconn, callback=in_time_bbox,
start=START_DATE, end=END_DATE,
xmin=XMIN, xmax=XMAX, ymin=YMIN, ymax=YMAX)
train_gen = TrackGen(train_qry.gen_qry(verbose=True), decimate=False)
TEST_START_DATE = datetime(2018, 8, 5, hour=0)
TEST_END_DATE = datetime(2018, 8, 6, hour=8)
test_qry = aisdb.DBQuery(dbconn=dbconn, callback=in_time_bbox,
start=TEST_START_DATE, end=TEST_END_DATE,
xmin=XMIN, xmax=XMAX, ymin=YMIN, ymax=YMAX)
test_gen = TrackGen(test_qry.gen_qry(verbose=True), decimate=False)def preprocess_aisdb_tracks(tracks_gen, proj, sog_scaler=None, feature_scaler=None, fit_scaler=False):
# Chain the AISdb generators first, then materialize the tracks.
tracks_gen = aisdb.remove_pings_wrt_speed(tracks_gen, 0.1)
tracks_gen = aisdb.encode_greatcircledistance(tracks_gen, distance_threshold=50000,
minscore=1e-5, speed_threshold=50)
tracks_gen = aisdb.interp_time(tracks_gen, step=timedelta(minutes=5))
tracks = list(tracks_gen)
tracks_by_mmsi = defaultdict(list)
for track in tracks:
tracks_by_mmsi[track['mmsi']].append(track)
# Short tracks give the model almost nothing to learn from.
valid_tracks = []
for mmsi, mmsi_tracks in tracks_by_mmsi.items():
if all(len(t['time']) >= 100 for t in mmsi_tracks):
valid_tracks.extend(mmsi_tracks)
rows = []
for track in valid_tracks:
sog = track.get('sog', [np.nan] * len(track['time']))
cog = track.get('cog', [np.nan] * len(track['time']))
for i in range(len(track['time'])):
x, y = proj(track['lon'][i], track['lat'][i])
cog_rad = np.radians(cog[i]) if cog[i] is not None else np.nan
rows.append({
'mmsi': track['mmsi'], 'x': x, 'y': y, 'sog': sog[i],
'cog_sin': np.sin(cog_rad) if not np.isnan(cog_rad) else np.nan,
'cog_cos': np.cos(cog_rad) if not np.isnan(cog_rad) else np.nan,
'timestamp': pd.to_datetime(track['time'][i], errors='coerce'),
})
df = pd.DataFrame(rows).replace([np.inf, -np.inf], np.nan)
df = df.dropna(subset=['x', 'y', 'sog', 'cog_sin', 'cog_cos'])
feature_cols = ['x', 'y', 'sog', 'cog_sin', 'cog_cos']
if fit_scaler:
sog_scaler = RobustScaler()
df['sog_scaled'] = sog_scaler.fit_transform(df[['sog']])
feature_scaler = RobustScaler()
df[feature_cols] = feature_scaler.fit_transform(df[feature_cols])
else:
df['sog_scaled'] = sog_scaler.transform(df[['sog']])
df[feature_cols] = feature_scaler.transform(df[feature_cols])
return df, sog_scaler, feature_scaler
proj = pyproj.Proj(proj='utm', zone=20, ellps='WGS84') # lat/lon -> meters, zone 20 fits this extract
train_df, sog_scaler, feature_scaler = preprocess_aisdb_tracks(train_gen, proj, fit_scaler=True)
test_df, _, _ = preprocess_aisdb_tracks(test_gen, proj, sog_scaler=sog_scaler,
feature_scaler=feature_scaler, fit_scaler=False)def create_sequences(df, features, input_size=80, output_size=2, step=1):
X_list, Y_list = [], []
for mmsi in df['mmsi'].unique():
sub = df[df['mmsi'] == mmsi].sort_values('timestamp')[features].to_numpy()
for i in range(0, len(sub) - input_size - output_size + 1, step):
X_list.append(sub[i:i + input_size])
Y_list.append(sub[i + input_size:i + input_size + output_size])
return torch.tensor(X_list, dtype=torch.float32), torch.tensor(Y_list, dtype=torch.float32)
features = ['x', 'y', 'cog_sin', 'cog_cos', 'sog_scaled']
mmsis = train_df['mmsi'].unique()
train_mmsi, val_mmsi = train_test_split(mmsis, test_size=0.2, random_state=42, shuffle=True)
train_X, train_Y = create_sequences(train_df[train_df['mmsi'].isin(train_mmsi)], features)
val_X, val_Y = create_sequences(train_df[train_df['mmsi'].isin(val_mmsi)], features)
test_X, test_Y = create_sequences(test_df, features)
batch_size = 64
train_dl = DataLoader(TensorDataset(train_X, train_Y), batch_size=batch_size, shuffle=True)
val_dl = DataLoader(TensorDataset(val_X, val_Y), batch_size=batch_size, shuffle=False)
test_dl = DataLoader(TensorDataset(test_X, test_Y), batch_size=batch_size)class Seq2SeqLSTM(nn.Module):
def __init__(self, input_size, hidden_size, input_steps, output_steps):
super().__init__()
self.input_steps = input_steps
self.output_steps = output_steps
self.encoder = nn.LSTM(input_size, hidden_size, num_layers=2, dropout=0.3, batch_first=True)
self.decoder = nn.LSTMCell(input_size, hidden_size)
self.attn = nn.Linear(hidden_size + hidden_size, input_steps)
self.attn_combine = nn.Linear(hidden_size + input_size, input_size)
self.output_layer = nn.Sequential(
nn.Linear(hidden_size, hidden_size // 2),
nn.ReLU(),
nn.Linear(hidden_size // 2, input_size),
)
def forward(self, x, target_seq=None, teacher_forcing_ratio=0.5):
"""x: [B, T_in, F] -> [B, T_out, F], with x,y decoded as last_obs + residuals."""
encoder_outputs, (h, c) = self.encoder(x)
h, c = h[-1], c[-1]
last_obs = x[:, -1, :]
last_xy = last_obs[:, :2]
decoder_input = last_obs
outputs = []
for t in range(self.output_steps):
attn_weights = torch.softmax(self.attn(torch.cat((h, c), dim=1)), dim=1)
context = torch.bmm(attn_weights.unsqueeze(1), encoder_outputs).squeeze(1)
dec_in = self.attn_combine(torch.cat((decoder_input, context), dim=1))
h, c = self.decoder(dec_in, (h, c))
# Residual applies to x,y only; the other features are predicted directly.
residual = self.output_layer(h)
out = torch.cat([residual[:, :2] + last_xy, residual[:, 2:]], dim=1)
outputs.append(out.unsqueeze(1))
# Teacher forcing feeds the ground-truth step; otherwise autoregress.
if self.training and target_seq is not None and t < target_seq.size(1) \
and random.random() < teacher_forcing_ratio:
decoder_input = target_seq[:, t, :]
last_xy = decoder_input[:, :2]
else:
decoder_input = out
last_xy = out[:, :2]
return torch.cat(outputs, dim=1)
def weighted_coord_loss(pred, target, coord_weight=5.0, reduction='mean'):
"""Weight coordinate errors (first two dims) above the auxiliary features."""
coord_loss = F.smooth_l1_loss(pred[..., :2], target[..., :2], reduction=reduction)
aux_loss = F.smooth_l1_loss(pred[..., 2:], target[..., 2:], reduction=reduction)
return coord_weight * coord_loss + aux_loss
def xy_smoothness_loss(seq_full):
"""L2 penalty on velocity and acceleration of the x,y channels."""
xy = seq_full[..., :2]
v = xy[:, 1:, :] - xy[:, :-1, :]
a = v[:, 1:, :] - v[:, :-1, :]
return (v ** 2).mean() * 0.05 + (a ** 2).mean() * 0.5def train_model(model, loader, val_dl, optimizer, device, epochs=50,
coord_weight=5.0, smooth_w_init=1e-3):
best_loss = float('inf')
best_state = None
for epoch in range(epochs):
model.train()
total_loss = total_data_loss = total_smooth_loss = 0.0
for batch_x, batch_y in loader:
batch_x, batch_y = batch_x.to(device), batch_y.to(device)
# Targets become residuals relative to the previous position.
y_start = batch_x[:, -1:, :2]
residual_y = batch_y.clone()
residual_y[..., :2] = batch_y[..., :2] - torch.cat([y_start, batch_y[:, :-1, :2]], dim=1)
optimizer.zero_grad()
pred_residuals = model(batch_x, target_seq=residual_y, teacher_forcing_ratio=0.5)
loss_data = weighted_coord_loss(pred_residuals, residual_y, coord_weight=coord_weight)
# The smoothness term needs the absolute xy sequence back.
pred_xy = torch.cumsum(pred_residuals[..., :2], dim=1) + y_start
full_seq = torch.cat([batch_x[..., :2], pred_xy], dim=1)
loss_smooth = xy_smoothness_loss(full_seq)
smooth_weight = smooth_w_init * max(0.1, 1.0 - epoch / 30.0)
loss = loss_data + smooth_weight * loss_smooth
if torch.isnan(loss) or torch.isinf(loss):
print("Skipping batch due to NaN/inf loss.")
continue
loss.backward()
optimizer.step()
total_loss += loss.item()
total_data_loss += loss_data.item()
total_smooth_loss += loss_smooth.item()
avg_loss = total_loss / len(loader)
print(f"Epoch {epoch+1:02d} | Total: {avg_loss:.6f} | Data: {total_data_loss/len(loader):.6f} | Smooth: {total_smooth_loss/len(loader):.6f}")
model.eval()
val_loss = 0.0
with torch.no_grad():
for xb, yb in val_dl:
xb, yb = xb.to(device), yb.to(device)
y_start = xb[:, -1:, :2]
residual_y = yb.clone()
residual_y[..., :2] = yb[..., :2] - torch.cat([y_start, yb[:, :-1, :2]], dim=1)
pred_residuals = model(xb, target_seq=residual_y, teacher_forcing_ratio=0.0)
data_loss = weighted_coord_loss(pred_residuals, residual_y, coord_weight=coord_weight)
pred_xy = torch.cumsum(pred_residuals[..., :2], dim=1) + y_start
full_seq = torch.cat([xb[..., :2], pred_xy], dim=1)
val_loss += (data_loss + smooth_weight * xy_smoothness_loss(full_seq)).item()
val_loss /= len(val_dl)
print(f" Val Loss: {val_loss:.6f}")
if val_loss < best_loss:
best_loss, best_state = val_loss, model.state_dict()
if best_state is not None:
torch.save(best_state, "best_model_seq2seq_residual_xy_08302.pth")
print("Best model saved")
SEED = 42
torch.manual_seed(SEED)
np.random.seed(SEED)
random.seed(SEED)
torch.cuda.manual_seed_all(SEED)
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = Seq2SeqLSTM(input_size=5, hidden_size=64, input_steps=80, output_steps=2).to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
train_model(model, train_dl, val_dl, optimizer, device)def inverse_xy_only(xy_scaled, scaler):
"""Inverse-transform the x,y columns only (StandardScaler or RobustScaler)."""
center = scaler.mean_[:2] if hasattr(scaler, "mean_") else scaler.center_[:2]
return xy_scaled * scaler.scale_[:2] + center
def haversine(lon1, lat1, lon2, lat2):
"""Distance in meters between two lon/lat points."""
lon1, lat1, lon2, lat2 = map(np.radians, [lon1, lat1, lon2, lat2])
a = np.sin((lat2 - lat1) / 2.0) ** 2 + np.cos(lat1) * np.cos(lat2) * np.sin((lon2 - lon1) / 2.0) ** 2
return 2 * 6371000.0 * np.arcsin(np.sqrt(a))
def evaluate_with_errors(model, test_dl, proj, feature_scaler, device,
residual_std, num_batches=1, dup_tol=1e-4):
"""Decode residual predictions to lat/lon, print per-step errors, plot the track."""
model.eval()
errors_all, batches = [], 0
with torch.no_grad():
for xb, yb in test_dl:
xb, yb = xb.to(device), yb.to(device)
pred = model(xb, teacher_forcing_ratio=0.0)
# First sample of the batch for diagnostics and plotting.
input_xy_s = xb[0, :, :2].cpu().numpy()
real_xy_s = yb[0, :, :2].cpu().numpy()
pred_xy_s = pred[0, :, :2].cpu().numpy()
last_obs_xy_m = inverse_xy_only(input_xy_s[-1], feature_scaler)
pred_xy_m = np.cumsum(pred_xy_s * residual_std, axis=0) + last_obs_xy_m
# Interpolation can duplicate the last observation as the first target.
if real_xy_s.shape[0] >= 1 and np.allclose(real_xy_s[0], input_xy_s[-1], atol=dup_tol):
real_xy_s = real_xy_s[1:]
min_len = min(pred_xy_m.shape[0], real_xy_s.shape[0])
if min_len == 0:
batches += 1
if batches >= num_batches:
break
continue
pred_xy_m = pred_xy_m[:min_len]
real_xy_m = inverse_xy_only(real_xy_s[:min_len], feature_scaler)
input_xy_m = inverse_xy_only(input_xy_s, feature_scaler)
lon_in, lat_in = proj(input_xy_m[:, 0], input_xy_m[:, 1], inverse=True)
lon_real, lat_real = proj(real_xy_m[:, 0], real_xy_m[:, 1], inverse=True)
lon_pred, lat_pred = proj(pred_xy_m[:, 0], pred_xy_m[:, 1], inverse=True)
print("\n=== Predicted vs True (lat/lon) ===")
print(f"{'t':>3} | {'lon_true':>9} | {'lat_true':>9} | {'lon_pred':>9} | {'lat_pred':>9} | {'err_m':>9}")
errors = []
for t in range(len(lon_real)):
err_m = haversine(lon_real[t], lat_real[t], lon_pred[t], lat_pred[t])
errors.append(err_m)
print(f"{t:3d} | {lon_real[t]:9.5f} | {lat_real[t]:9.5f} | {lon_pred[t]:9.5f} | {lat_pred[t]:9.5f} | {err_m:9.2f}")
errors_all.append(errors)
fig = plt.figure(figsize=(8, 6))
ax = plt.axes(projection=ccrs.PlateCarree())
all_lons = np.concatenate([lon_in, lon_real, lon_pred])
all_lats = np.concatenate([lat_in, lat_real, lat_pred])
ax.set_extent([all_lons.min() - 0.01, all_lons.max() + 0.01,
all_lats.min() - 0.01, all_lats.max() + 0.01],
crs=ccrs.PlateCarree())
ax.add_feature(cfeature.COASTLINE)
ax.add_feature(cfeature.LAND, facecolor="lightgray")
ax.add_feature(cfeature.OCEAN, facecolor="lightblue")
ax.plot(lon_in, lat_in, "o-", label="history", transform=ccrs.PlateCarree(),
markersize=6, linewidth=2)
ax.plot(lon_real, lat_real, "o-", label="true", transform=ccrs.PlateCarree(),
markersize=6, linewidth=2)
ax.plot(lon_pred, lat_pred, "x--", label="pred", transform=ccrs.PlateCarree(),
markersize=8, linewidth=2)
ax.legend()
plt.show()
batches += 1
if batches >= num_batches:
break
if errors_all:
errors_all = np.array(errors_all)
print("\n=== Summary (meters) ===")
for t, v in enumerate(errors_all.mean(axis=0)):
print(f"t={t} mean error: {v:.2f} m")
print(f"mean over horizon: {errors_all.mean():.2f} m, median: {np.median(errors_all):.2f} m")
# Per-axis std of the true xy residuals in meters, used to decode predictions.
all_resids = []
for i in range(train_X.shape[0]):
last_obs_m = inverse_xy_only(train_X[i, -1, :2].numpy(), feature_scaler)
true_m = inverse_xy_only(train_Y[i, :, :2].numpy(), feature_scaler)
if true_m.shape[0] == 0:
continue
resid0 = (true_m[0] - last_obs_m)[None, :]
resids = np.vstack([resid0, np.diff(true_m, axis=0)]) if true_m.shape[0] > 1 else resid0
all_resids.append(resids)
residual_std = np.std(np.vstack(all_resids), axis=0)
print("Computed residual_std (meters):", residual_std)
best_model = Seq2SeqLSTM(input_size=5, hidden_size=64, input_steps=80, output_steps=2).to(device) # best epoch, not last
best_model.load_state_dict(torch.load("best_model_seq2seq_residual_xy_08302.pth", map_location=device))
best_model.eval()
evaluate_with_errors(best_model, test_dl, proj, feature_scaler, device,
residual_std=residual_std, num_batches=1)Export static and interactive maps, and ask a Gemini model about the loaded data
The app works on any CSV with the usual AIS columns. For a sample that matches the rest of this section, decode the NOAA day file AIS_2020_01_01.zip with aisdb.decode_msgs and export a CSV as shown in AIS Data to CSV.
The H3 step relies on the aisdb.discretize module, which landed after the 1.8.0-alpha release, so install AISdb from the development branch (pip install git+https://github.com/MAPS-Lab/AISdb.git) if you want that step to work. The two chat tabs need langchain-google-genai and google-generativeai; without them, those tabs report that the SDK is missing instead of failing the whole app.
The interface is one Python script built on Gradio rather than a packaged AISdb module. Download it, export your Gemini key (only needed for the chat tabs), and run it.
The script starts a local Gradio server on port 7860. Open http://localhost:7860 in a browser to reach the interface.
Drag and drop your CSV onto the landing page. The app reports the total row count and summary statistics for the numeric columns, which is a quick sanity check that the file parsed the way you expected before you spend time processing it.
The Process tab is a checklist, and the app runs whatever you tick in order. These are the same functions (split_timedelta, encode_greatcircledistance, Discretizer, WeatherDataStore) you would otherwise call from a notebook.
Split by time breaks continuous tracks into segments at significant time gaps.
Encode track applies distance and speed thresholds to smooth and segment trajectories.
Discretize H3 converts coordinates into H3 hexagonal grid cells for spatial analysis.
Detect stops finds stopping periods from speed and duration thresholds.
Add bathymetry samples water depth along each track from a GeoTIFF.
Add weather joins weather parameters (wind, pressure, and so on) onto the tracks.
Each step exposes its parameters in the panel below the checklist.
Gap minutes (default 4320) is the maximum time gap between points before a new segment starts.
Time split (days) (default 15) caps the maximum duration of a segment.
Encode distance (m) (default 200000) and Encode speed (knots) (default 50) are the thresholds for track encoding.
H3 resolution (default 6) sets the hexagon size, from 0 to 15; higher values mean smaller cells.
Bathymetry raster points at the depth GeoTIFF; Weather shortnames lists parameters such as 10u,10v,msl.
Distance split (m) (default 30000) and Speed split (knots) (default 30) control the segmentation thresholds.
Two tabs render whatever the pipeline produced. Static Plot draws the tracks with Matplotlib and an optional Cartopy coastline, exporting PNG or PDF for a paper or report. Plotly OSM draws the same tracks on an OpenStreetMap layer you can zoom, pan, and hover, and exports a standalone HTML file to share.
The Explain tab forwards your question plus a summary of the loaded dataset to a Gemini model through LangChain and returns a plain-text answer, useful for row counts, gaps, or a quick read on a particular MMSI. The Gemini tab goes further and also sends along the plot you generated, so the model reasons about the image and the data together. Both need GOOGLE_API_KEY set, and both sit in the same territory as the RAG chatbot, except here the model reasons over your uploaded data rather than the documentation.
The screenshot in step 2 is what a successful session looks like at the start. From there, a typical pass on the NOAA sample takes a few clicks, tick Split by time and Encode track, run, then switch to Static Plot for a publication figure or Plotly OSM for interactive inspection, and export.
One Gradio script exposes AISdb's preprocessing pipeline to people who never touch Python.
Everything runs in memory on one uploaded file, right for exploring a day or a region, wrong for a multi-year database.
Nothing persists between sessions, so export anything you want to keep before closing the tab.
Treat the app as a fast way to inspect and enrich a sample before committing the same steps to a reproducible script.
To turn what the app does into code you can version and scale, start back at Clustering with Scikit Learn, the first page of this section.
Gradio: https://www.gradio.app/
AISdb source and documentation: https://github.com/MAPS-Lab/AISdb
H3 hierarchical hexagonal geospatial indexing system: https://h3geo.org/
Google Generative AI Python SDK: https://ai.google.dev/
pip install aisdb gradio pandas numpy rasterio plotly matplotlib cartopy h3 langchain-google-genai google-generativeaiexport GOOGLE_API_KEY="your-key-here"
python ais_bot.pyTurn one day of NOAA AIS data into a temporal transit graph with AISdb and train a Temporal Graph Network on it with PyTorch Geometric.
Maritime traffic is relational. A port is busy because ships arrive from many origins, and a strait is congested because dozens of routes funnel through it. This tutorial turns one day of open NOAA AIS data into a stream of timestamped zone-to-zone transits with AISdb, then trains a Temporal Graph Network (TGN) in PyTorch Geometric to predict which transit happens next. You walk away able to convert raw AIS into a TemporalData event stream and run an event-driven link-prediction loop on it.
Building zone-to-zone transit edges from raw AIS with aisdb.network_graph.graph
Loading a transit CSV into a PyTorch Geometric TemporalData event stream
Assembling a TGN from TGNMemory, an attention embedding, and a link predictor
Training and evaluating future-link prediction on chronological splits
Every number and figure on this page comes from one open dataset, the NOAA AIS day file for 2020-01-01 (about 200 MB zipped, from ), so anyone with AISdb installed can reproduce the whole run.
Decode the zipped archive straight into SQLite, then query the Gulf of Mexico window and vectorize the rows into per-vessel tracks. One day keeps the download small and is enough to see the pipeline work end to end.
The nodes come from a DomainFromPoints, three zones a few kilometers apart along the Houston Ship Channel, spaced so a single day of data actually contains same-day transits between them. Two AISdb behaviors matter here. graph derives each zone's integer id by stripping non-digits from its name, so every zone name must contain a number, and with a SQLiteDBConn the trafficDBpath argument must be a real string path (a fresh, empty SQLite file is fine). Passing data_dir=None skips the optional multi-gigabyte shore, port, and bathymetry rasters.
graph vectorizes the query, splits long gaps, denoises, geofences every track against the domain, and writes one CSV row per transit. Positions outside all zones are labeled Z0, an implicit open-water node that shows up as a genuine hub in the graph. We use src_zone and rcv_zone as endpoints, last_seen_in_zone as the event time, and total_distance_meters, velocity_knots_avg, and minutes_spent_in_zone as edge features. The final segment of every track has no onward transit, so AISdb writes its rcv_zone as the string NULL and we drop those rows.
TemporalData holds the event stream as parallel src, dst, t, and msg tensors. Two pandas details are load-bearing. read_csv turns the NULL string into NaN, so filter with dropna, and the minute-resolution timestamps parse to datetime64[ms], which silently breaks the usual .astype("int64") // 10**9 idiom, so convert through pd.Timestamp.timestamp
On the NOAA day file this leaves 358 true transit events spanning 16.3 hours, over the three channel zones plus the Z0 open-water node.
A TGN (Rossi et al., 2020, ) keeps a memory vector per node that summarizes every event the node has taken part in. Each new edge updates the memory of both endpoints, a TransformerConv embedding attends over recent neighbors while folding in the elapsed time since each interaction, and a small MLP scores candidate edges.
Future-link prediction trains as binary classification. For every real transit we sample one negative destination, push real scores up and fake scores down, replay events in time order, and reset the memory at the start of each epoch. Evaluation reuses the memory left behind by training and keeps replaying events chronologically, without gradients.
With 358 events split three ways, the validation average precision is noisy and bounces between roughly 0.54 and 0.77 rather than converging cleanly. That is expected at demonstration scale. The loss drifts down as the memory learns the channel's rhythm and the pipeline is correct end to end, but a few hundred edges cannot pin down a stable metric. Treat these numbers as a sanity check that training runs, not as a benchmark to reproduce. Once trained at real scale, the same model ranks likely destinations for a vessel leaving a zone, predicts inflow per zone, and flags surprising transits for anomaly detection.
A temporal graph keeps the timeline a static adjacency matrix throws away, and AISdb's graph builds the event stream directly from raw AIS.
Zone names must embed a numeric id, and the Z0 open-water label is a real hub node, not a bug.
The TGN memory updates per event, so the model reacts to new AIS without a full retrain.
Next, turns this documentation itself into a question-answering system.
Build a documentation chatbot with Retrieval-Augmented Generation. Scrape the AISViz docs, index them in ChromaDB, and ground Gemini answers in real pages that every response cites.
A raw language model asked about AISdb will confidently invent function names that were never in the library. In this tutorial you build a chatbot that cannot do that, because every answer is generated from passages retrieved out of the real AISViz documentation and each response reports the source pages it leaned on. The whole pipeline, scraper to web interface, runs locally on a laptop and drops onto a Hugging Face Space unchanged when you want a public URL.
Scrape live documentation sites into a clean text corpus
Split long pages into overlapping chunks sized for retrieval
Embed chunks with SentenceTransformers and store them in a ChromaDB vector store
Retrieve top-k context per question and generate a grounded, source-citing Gemini answer
Serve the chatbot through Gradio and publish it as a Hugging Face Space
No AIS file is needed on this page. The corpus is the documentation itself, crawled live from and in step 1. You also need a Google AI Studio API key (any LangChain-supported provider works the same way), exported before you start.
The pipeline has two phases. Indexing runs offline and turns the docs into a searchable vector store; retrieval and generation run at question time.
The chatbot can only answer from pages it has seen, so the first step crawls both public sites, follows same-domain links, and keeps the main content block of every page along with its URL for later attribution.
The page count moves as the documentation grows, and answers are only ever as current as the last crawl, so re-run this step whenever pages change.
Whole pages are too long to retrieve precisely and would not fit a prompt anyway. Splitting into roughly 1000-character chunks with 200 characters of overlap keeps each chunk focused while the overlap stops sentences from being cut mid-thought at chunk borders.
Retrieval works by comparing vectors, so every chunk gets encoded once with the compact all-MiniLM-L6-v2 SentenceTransformer and written into a persistent Chroma collection. The ./chroma_db directory survives restarts, which means you index once and query many times.
At question time the pipeline embeds the question with the same model, pulls the four most similar chunks from Chroma, and hands them to Gemini with instructions to answer only from that context and to say so when the context does not contain the answer. That last instruction is what keeps the bot from inventing APIs.
A chat window makes the bot usable by people who will never open the script. Gradio's ChatInterface wraps answer_question in a few lines and appends the retrieved sources to every reply so users can verify claims themselves.
demo.launch() starts the app on a local URL, usually http://127.0.0.1:7860, and it comes up in under a minute once the index is built.
The same script becomes a public chatbot with no code changes. Create a Space, upload the file as app.py, list the packages from Prerequisites in a requirements.txt, and add GOOGLE_API_KEY as a Space secret. The Space runs the identical code you tested locally, so there is nothing new to debug; the full workflow is in the .
A run against a freshly indexed docs collection answers the test question like this.
The wording and the exact sources depend on which pages you indexed and which Gemini version answers, so expect the substance rather than the bytes to match. If an answer comes back thin or wrong, open the cited source page, it is usually the page that needs improving, then re-crawl and re-index.
Grounding a model in retrieved documentation replaces invented function names with answers that cite real pages.
The index is a snapshot. Re-run the scrape-chunk-embed steps whenever the docs change, or the bot answers from stale text.
Chunk size and overlap trade retrieval precision against context. The 1000/200 defaults work well for documentation prose.
A hosted model still sounds confident on thin context, which is why every response carries its sources for checking.
Next, wraps AISdb preprocessing and a similar chat assistant in a point-and-click Gradio app.

Scale is the main lever. More zones (or an H3 tessellation, see ) and more days turn the noisy demo metric into a real benchmark.
pip install aisdb torch torch-geometric scikit-learn pandasfrom datetime import datetime
from aisdb import DBQuery, SQLiteDBConn, TrackGen, decode_msgs
from aisdb.database import sqlfcn_callbacks
DB_PATH = "./noaa_20200101.db"
ZIP_PATH = "./AIS_2020_01_01.zip"
with SQLiteDBConn(dbpath=DB_PATH) as dbconn:
decode_msgs(filepaths=[ZIP_PATH], dbconn=dbconn, source="NOAA", verbose=True)
qry = DBQuery(
dbconn=dbconn,
start=datetime(2020, 1, 1), end=datetime(2020, 1, 2),
xmin=-98, xmax=-80, ymin=24, ymax=31,
callback=sqlfcn_callbacks.in_bbox_time_validmmsi,
)
tracks = list(TrackGen(qry.gen_qry(), decimate=False))
print(len(tracks), "vessel tracks")5726 vessel tracks (1,944,019 positions)from datetime import timedelta
from aisdb import DomainFromPoints, graph
from aisdb.database.sqlfcn_callbacks import in_bbox_time
TRAFFIC_DB = "./traffic.db"
EDGE_CSV = "./transit_graph.csv"
# Zone centers (lon, lat); each name embeds the numeric id graph() requires.
domain = DomainFromPoints(
points=[(-95.02, 29.72), (-94.83, 29.53), (-94.70, 29.33)],
radial_distances=[8000, 8000, 8000],
names=["1_Houston", "2_MidBay", "3_Galveston"],
)
with SQLiteDBConn(dbpath=DB_PATH) as dbconn:
qry = DBQuery(
dbconn=dbconn,
callback=in_bbox_time,
start=datetime(2020, 1, 1), end=datetime(2020, 1, 2),
**domain.boundary,
)
graph(
qry,
outputfile=EDGE_CSV,
domain=domain,
dbconn=dbconn,
data_dir=None, # skip the optional raster downloads
trafficDBpath=TRAFFIC_DB,
maxdelta=timedelta(hours=24),
speed_threshold=50,
distance_threshold=200000,
interp_delta=timedelta(minutes=10),
verbose=True,
)import numpy as np
import pandas as pd
import torch
from torch_geometric.data import TemporalData
df = pd.read_csv(EDGE_CSV)
# Keep only true transits: both endpoints must be real zones.
df = df.dropna(subset=["rcv_zone"])
df = df[df["rcv_zone"].astype(str) != "NULL"].copy()
df["src_zone"] = df["src_zone"].astype(int)
df["rcv_zone"] = df["rcv_zone"].astype(int)
# Remap zone ids to contiguous node indices.
zone_ids = sorted(set(df["src_zone"]) | set(df["rcv_zone"]))
zone_to_idx = {z: i for i, z in enumerate(zone_ids)}
src = df["src_zone"].map(zone_to_idx).to_numpy()
dst = df["rcv_zone"].map(zone_to_idx).to_numpy()
# Event time in seconds since the epoch, safe at any datetime64 resolution.
parsed = pd.to_datetime(
df["last_seen_in_zone"].str.replace(" UTC", "", regex=False),
format="%Y-%m-%d %H:%M", utc=True,
)
t = parsed.map(pd.Timestamp.timestamp).astype("int64")
cols = ["total_distance_meters", "velocity_knots_avg", "minutes_spent_in_zone"]
msg = (df[[c for c in cols if c in df.columns]].replace("NULL", np.nan)
.apply(pd.to_numeric, errors="coerce").fillna(0.0).to_numpy(dtype="float32"))
msg = (msg - msg.mean(0)) / (msg.std(0) + 1e-9) # standardize features
order = np.argsort(t.to_numpy())
data = TemporalData(
src=torch.as_tensor(src[order], dtype=torch.long),
dst=torch.as_tensor(dst[order], dtype=torch.long),
t=torch.as_tensor(t.to_numpy()[order], dtype=torch.long),
msg=torch.as_tensor(msg[order], dtype=torch.float),
)
num_nodes = len(zone_ids)
print(data)
train_data, val_data, test_data = data.train_val_test_split(val_ratio=0.15, test_ratio=0.15)TemporalData(src=[358], dst=[358], t=[358], msg=[358, 3])from torch.nn import Linear
from torch_geometric.nn import TransformerConv
from torch_geometric.nn.models.tgn import (
TGNMemory, IdentityMessage, LastAggregator, LastNeighborLoader,
)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
memory_dim = time_dim = embedding_dim = 100
msg_dim = data.msg.size(-1)
class GraphAttentionEmbedding(torch.nn.Module):
def __init__(self, in_channels, out_channels, msg_dim, time_enc):
super().__init__()
self.time_enc = time_enc
edge_dim = msg_dim + time_enc.out_channels
self.conv = TransformerConv(in_channels, out_channels // 2, heads=2,
dropout=0.1, edge_dim=edge_dim)
def forward(self, x, last_update, edge_index, t, msg):
rel_t = last_update[edge_index[0]] - t
edge_attr = torch.cat([self.time_enc(rel_t.to(x.dtype)), msg], dim=-1)
return self.conv(x, edge_index, edge_attr)
class LinkPredictor(torch.nn.Module):
def __init__(self, in_channels):
super().__init__()
self.lin_src = Linear(in_channels, in_channels)
self.lin_dst = Linear(in_channels, in_channels)
self.lin_final = Linear(in_channels, 1)
def forward(self, z_src, z_dst):
h = (self.lin_src(z_src) + self.lin_dst(z_dst)).relu()
return self.lin_final(h)
torch.manual_seed(42)
memory = TGNMemory(
num_nodes, msg_dim, memory_dim, time_dim,
message_module=IdentityMessage(msg_dim, memory_dim, time_dim),
aggregator_module=LastAggregator(),
).to(device)
gnn = GraphAttentionEmbedding(memory_dim, embedding_dim, msg_dim,
memory.time_enc).to(device)
link_pred = LinkPredictor(embedding_dim).to(device)
neighbor_loader = LastNeighborLoader(num_nodes, size=10, device=device)
optimizer = torch.optim.Adam(
set(memory.parameters()) | set(gnn.parameters()) | set(link_pred.parameters()),
lr=1e-3,
)
criterion = torch.nn.BCEWithLogitsLoss()from sklearn.metrics import average_precision_score
from torch_geometric.loader import TemporalDataLoader
train_loader = TemporalDataLoader(train_data, batch_size=32)
val_loader = TemporalDataLoader(val_data, batch_size=32)
test_loader = TemporalDataLoader(test_data, batch_size=32)
# Maps global node ids to the local index used inside a batch.
assoc = torch.empty(num_nodes, dtype=torch.long, device=device)
def run_batch(batch):
"""Score one chronological batch, returning positive and negative logits."""
neg_dst = torch.randint(0, num_nodes, (batch.dst.size(0),),
dtype=torch.long, device=device)
n_id = torch.cat([batch.src, batch.dst, neg_dst]).unique()
neighbor_loader.insert(batch.src, batch.dst)
n_id, edge_index, e_id = neighbor_loader(n_id)
assoc[n_id] = torch.arange(n_id.size(0), device=device)
z, last_update = memory(n_id)
z = gnn(z, last_update, edge_index,
data.t[e_id].to(device), data.msg[e_id].to(device))
pos_out = link_pred(z[assoc[batch.src]], z[assoc[batch.dst]])
neg_out = link_pred(z[assoc[batch.src]], z[assoc[neg_dst]])
return pos_out, neg_out
def train():
for m in (memory, gnn, link_pred):
m.train()
memory.reset_state() # start each epoch with an empty memory
neighbor_loader.reset_state()
total_loss = 0.0
for batch in train_loader:
batch = batch.to(device)
optimizer.zero_grad()
pos_out, neg_out = run_batch(batch)
loss = criterion(pos_out, torch.ones_like(pos_out))
loss += criterion(neg_out, torch.zeros_like(neg_out))
memory.update_state(batch.src, batch.dst, batch.t, batch.msg)
loss.backward()
optimizer.step()
memory.detach()
total_loss += float(loss) * batch.num_events
return total_loss / train_data.num_events
@torch.no_grad()
def evaluate(loader):
for m in (memory, gnn, link_pred):
m.eval()
aps = []
for batch in loader:
batch = batch.to(device)
pos_out, neg_out = run_batch(batch)
y_true = torch.cat([torch.ones_like(pos_out), torch.zeros_like(neg_out)])
y_score = torch.cat([pos_out.sigmoid(), neg_out.sigmoid()])
aps.append(average_precision_score(y_true.cpu(), y_score.cpu()))
memory.update_state(batch.src, batch.dst, batch.t, batch.msg)
return float(np.mean(aps))
for epoch in range(1, 31):
loss = train()
val_ap = evaluate(val_loader)
if epoch % 10 == 0 or epoch == 1:
print(f"Epoch {epoch:02d} loss {loss:.4f} val AP {val_ap:.4f}")Epoch 01 loss 1.3777 val AP 0.6311
Epoch 10 loss 1.3863 val AP 0.6604
Epoch 20 loss 1.3246 val AP 0.5388
Epoch 30 loss 1.1843 val AP 0.7728pip install langchain "langchain[google-genai]" chromadb sentence-transformers gradio beautifulsoup4 requestsexport GOOGLE_API_KEY="your-api-key-here"import time
import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin, urlparse
def scrape_aisviz_docs():
"""Crawl the AISViz docs and return dicts of url, title, and content."""
base_urls = [
"https://aisviz.gitbook.io/documentation",
"https://aisviz.cs.dal.ca",
]
scraped, visited = [], set()
def grab(url):
response = requests.get(url, timeout=30)
soup = BeautifulSoup(response.content, "html.parser")
main = soup.find("div", class_="page-content") or soup.find("main")
if main:
title = soup.find("title")
scraped.append({
"url": url,
"title": title.get_text() if title else "AISViz Documentation",
"content": main.get_text(strip=True),
})
return soup
for base_url in base_urls:
soup = grab(base_url)
for link in soup.find_all("a", href=True):
full_url = urljoin(base_url, link["href"])
same_site = urlparse(full_url).netloc == urlparse(base_url).netloc
if not same_site or full_url in visited:
continue
visited.add(full_url)
try:
time.sleep(0.5) # be polite to the server
grab(full_url)
except Exception as e:
print(f"error scraping {full_url}: {e}")
return scraped
docs_data = scrape_aisviz_docs()
print(f"scraped {len(docs_data)} pages")def split_text(text, chunk_size=1000, chunk_overlap=200):
"""Split text into overlapping chunks, breaking at natural boundaries."""
chunks, start = [], 0
while start < len(text):
end = start + chunk_size
if end < len(text): # prefer a sentence or word boundary
for boundary in ('.', '\n', ' '):
cut = text.rfind(boundary, start, end)
if cut > start + chunk_size // 2:
end = cut + 1
break
chunk = text[start:end].strip()
if chunk:
chunks.append(chunk)
start = end - chunk_overlap if end < len(text) else end
return chunks
all_chunks, chunk_metadata = [], []
for doc in docs_data:
for i, chunk in enumerate(split_text(doc["content"])):
all_chunks.append(chunk)
chunk_metadata.append(
{"source": doc["url"], "title": doc["title"], "chunk_id": i}
)
print(f"split documentation into {len(all_chunks)} chunks")import chromadb
from sentence_transformers import SentenceTransformer
embeddings_model = SentenceTransformer("all-MiniLM-L6-v2")
chroma_client = chromadb.PersistentClient(path="./chroma_db")
collection = chroma_client.get_or_create_collection(name="aisviz_docs")
chunk_embeddings = embeddings_model.encode(all_chunks, show_progress_bar=True)
collection.add(
embeddings=chunk_embeddings.tolist(),
documents=all_chunks,
metadatas=chunk_metadata,
ids=[f"chunk_{i}" for i in range(len(all_chunks))],
)
print("documents indexed and stored in Chroma")import getpass
import os
from langchain.chat_models import init_chat_model
if not os.environ.get("GOOGLE_API_KEY"):
os.environ["GOOGLE_API_KEY"] = getpass.getpass("Enter API key for Google Gemini: ")
model = init_chat_model("gemini-2.5-flash", model_provider="google_genai")
SYSTEM_PROMPT = (
"You are an assistant for question-answering tasks about AISViz documentation "
"and maritime vessel tracking. Use the retrieved context to answer the question. "
"If you don't know the answer based on the context, say that you don't know. "
"Keep the answer concise and mention which sources you are referencing."
)
def answer_question(question, k=4):
"""Answer a question from the indexed AISViz documentation."""
question_embedding = embeddings_model.encode([question])
results = collection.query(
query_embeddings=question_embedding.tolist(), n_results=k
)
docs = results["documents"][0]
metas = results["metadatas"][0]
context = "\n\n".join(
f"Source: {meta.get('title', 'AISViz Documentation')}\n{doc}"
for doc, meta in zip(docs, metas)
)
prompt = f"{SYSTEM_PROMPT}\n\nContext:\n{context}\n\nQuestion: {question}\n\nAnswer:"
response = model.invoke(prompt)
return {
"answer": response.content,
"sources": [meta.get("source", "") for meta in metas],
}
result = answer_question("What is AISViz?")
print("Answer:", result["answer"])
print("Sources:", result["sources"])import gradio as gr
def chatbot_interface(message, history):
result = answer_question(message)
response = result["answer"]
unique_sources = list(set(result["sources"][:3]))
if unique_sources:
response += "\n\n**Sources:**\n" + "\n".join(f"- {s}" for s in unique_sources)
return response
demo = gr.ChatInterface(
fn=chatbot_interface,
title="AISViz Documentation Chatbot",
description="Ask questions about AISViz, AISdb, and maritime vessel tracking.",
examples=[
"What is AISViz?",
"How do I get started with AISdb?",
"How can I analyze vessel trajectories?",
],
)
if __name__ == "__main__":
demo.launch()Answer: AISViz is a platform for collecting, storing, and analyzing AIS
(Automatic Identification System) vessel tracking data. AISdb is the Python
package that powers it, giving you tools to decode raw AIS messages, query
them from SQLite or PostgreSQL, and build vessel tracks for analysis. It's
built for researchers working with maritime traffic and marine spatial data.
Sources: ['https://aisviz.gitbook.io/documentation', 'https://aisviz.cs.dal.ca']
