Only this pageAll pages
Powered by GitBook
1 of 33

Documentation

Loading...

Default Start

Loading...

Loading...

Loading...

Tutorials

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Machine Learning

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Keep Exploring

Quick Start

A hands-on quick start guide for using AISdb.

If you are new to AIS topics, to learn about the Automatic Identification System (AIS).

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.

Python Environment and Installation

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.

User Installation

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.

install.sh
python -m venv 
install.ps1
python -m venv 

PyPI carries the latest stable line of AISdb. The 1.8.0-alpha release that this documentation targets is published on and installs from source. If you need the features introduced in 1.8.0-alpha, such as weather integration, NOAA CSV ingestion, and TimescaleDB support, follow the guide or run pip install git+https://github.com/MAPS-Lab/AISdb.git with a Rust toolchain installed. The core workflow shown in this guide works the same on both.

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 release

If you are running Jupyter, ensure it is installed in the same environment as AISdb:

source ./.venv/bin/activate
pip install jupyter
jupyter notebook

The 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.

1
2
3
4

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.

Introduction

A little bit about where we stand.

Overview

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.

SQL database for storing AIS position reports and vessel metadata

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.

Vessel data cleaning and trajectory modeling

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.

Integration with environmental context and external metadata

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.

AIS Hardware

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.

Pipeline for capturing and sharing your own AIS data with a VHF Antenna and AISdb.

Requirements

  • Raspberry Pi or other computers with internet connectivity

Raspberry Pi (Image source: )
  • 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.

1

Attach the dAISy (or equivalent) to the Pi over USB, log in, and update the system with sudo apt-get update.

2

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.

3

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.

SQL Database

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.

Choosing a Database Backend

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.

Database Loading

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:

Data Cleaning

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).

  • Advanced features for maritime studies

    Python interface and machine learning for vessel behavior modeling

    Research support

    Our Team

    Active Members

    Adjunct Members

    Former Members

    Contact

    Modeling and Analytics for Predictive Systems (MAPS)
    Maritime Risk and Safety (MARS)
    Department of Fisheries and Oceans Canada (DFO)
    rsong@dal.ca
    vaishnav@dal.ca
    GitHub team platform
    .venv
    # create a python virtual environment
    source ./.venv/bin/activate # activate the virtual environment
    pip install aisdb # from https://pypi.org/project/aisdb/
    .venv
    ./AISdb/Scripts/activate
    pip install aisdb

    Development Installation

    Database Handling

    Install TimescaleDB (PostgreSQL Extension)

    Enable the Extension in PostgreSQL

    Verify the Installation

    Restart PostgreSQL

    Connecting to a PostgreSQL database

    Attaching a SQLite database to AISdb

    Querying the Database

    Processing

    Voyage Modelling

    Data cleaning and MMSI deduplication

    Interpolating, geofencing, and filtering

    Exporting as CSV

    Integration with external metadata

    Bathymetric charts

    Rasters

    Visualization

    Where to go next

    TimescaleDB
    keyword arguments
    here
    SQL Database
    Marine Cadastre
    Tutorial
    tutorial
    aisdb.database.dbqry.DBQuery
    aisdb.database.dbqry.DBQuery.gen_qry()
    aisdb.track_gen.TrackGen()
    aisdb.gis.Domain
    aisdb.database.sql_query_strings
    aisdb.database.sqlfcn_callbacks
    aisdb.denoising_encoder.encode_greatcircledistance()
    aisdb.track_gen
    aisdb.webdata.bathymetry
    Gebco()
    TrackGen()
    aisdb.webdata.shore_dist.ShoreDist
    aisdb.web_interface.visualize()
    aisdb.track_gen.TrackGen()
    Google Colab Notebook
    Tutorials
    database loading
    data querying
    data cleaning
    click here
    GitHub
    Visualization of vessel tracks within a defined time range
    Compile AISdb
    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,docs
    colab_setup.py
    import 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 postgresql
    from 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 data
    tracks = 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,
        )
    Validate the hardware configuration
    • 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.

    Install mproxy-client

    Install it from crates.io by running cargo install mproxy-client.

    4

    Create a systemd service

    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.

    mproxy_client.service
    [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.target

    This 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.

    5

    Link and enable the service

    Starts the bridge at boot.

    sudo systemctl enable systemd-networkd-wait-online.service
    sudo systemctl link ./mproxy_client.service
    sudo systemctl daemon-reload
    sudo systemctl enable mproxy_client
    sudo systemctl start mproxy_client
    pip install aisdb
    ./run_receiver.py
    import aisdb
    
    aisdb.start_receiver(
        udp_listen_addr="127.0.0.1:9921",
        sqlite_dbpath="/home/ais/ais_rx.db",
        connect_addr=None,
    )
    ls -l /dev
    stty -F /dev/ttyS0 38400 cs8 -cstopb -parenb

    Hardware Setup

    Software Setup

    Install AISdb

    Bridge the serial port to a network socket

    Connect the receiver

    Install the Rust toolchain

    Run the AISdb receiver

    💡 Common Issues

    Wegmatt dAISy 2 Channel Receiver
    this one sold by Uputronics
    MarineTraffic
    https://www.raspberrypi.com/products/raspberry-pi-3-model-b/
    MERIDIAN AIS hardware setup working at Sandy Cove in Halifax, NS - Canada.
    $ 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*20

    SQLite 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,
            )
    Visualization of vessel tracks queried from SQLite database created from test data

    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:

    Visualization of tracks queried from 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.

    Linux
    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/
    Windows
    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.6
    from datetime import datetime, timedelta
    import os
    import aisdb
    import nest_asyncio
    nest_asyncio.apply()

    Install Requirements

    Load AIS data into a database

    Jupyter
    spire_year_ingest.py
    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}')
                    continue
    from 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.
        )

    Example: Processing a Full Year of Spire Data (2024)

    is the scoring function that
    encode_greatcircledistance()
    uses internally to decide which segments belong to the same voyage. It is also available on its own for custom pipelines.
  • 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.

    Queried vessel tracks after applying track segmentation and encoder (distance threshold=20km, speed threshold=50knots)

    For comparison, here is the same area before cleaning.

    Queried vessel tracks 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.

    1. Query and generate tracks with DBQuery and aisdb.TrackGen.

    2. Split tracks on large time gaps with aisdb.split_timedelta().

    3. Drop anchored vessels with aisdb.remove_pings_wrt_speed(), if stationary traffic is not of interest.

    4. Re-link segments and separate duplicate-identifier tracks with aisdb.encode_greatcircledistance().

    5. Optionally, remove land-locked points with InlandDenoising if the query area includes a coastline.

    6. 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.

    aisdb.denoising_encoder
    clean_tracks.py
    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 187

    Segmenting and re-linking tracks with encode_greatcircledistance()

    How segmentation and re-linking work

    Example

    To color-code the resulting tracks before visualizing, write a small generator that sets a color key on each track dict, for example track['color'] = 'red' or an RGB string such as 'rgb(255,0,0)', and pass the output through it before calling aisdb.web_interface.visualize().

    Removing anchored vessels with remove_pings_wrt_speed()

    Filtering points on land with InlandDenoising

    Putting it together

    from 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 message
    static_{YYYYMM}_aggregate # table of aggregated static vessel data
    coarsetype_ref # a reference table that maps numeric ship type codes to their descriptions
    import 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)

    Table Naming

    Custom SQL Queries

    Optional TimescaleDB Backend

    Detailed Table Description

    ais_{YYYYMM}_dynamic tables
    Column
    Data Type
    Description
    ais_{YYYYMM}_static tables
    Column
    Data Type
    Description
    static_{YYYYMM}_aggregate tables
    Column
    Data Type
    Description

    Where to go next

    libpq
    decode_msgs
    Detailed Table Description
    DBQuery
    online tutorials
    Detailed Table Description
    aisdb_sql/
    TimescaleDB
    decode_msgs
    hypertables
    database loading
    data querying

    Track Interpolation

    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.

    Example data preparation

    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')
    Original data points of the vessel track queried from database

    Linear interpolation

    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.

    With equal time window intervals

    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.

    Data Querying

    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.

    Query functions

    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.

    Query database

    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

    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.

    Vessel Speed

    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:

    Speed(knot)=HaversineDistanceTime×1.9438445Speed(knot) = \frac{Haversine Distance}{Time} \times 1.9438445Speed(knot)=TimeHaversineDistance​×1.9438445

    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 equal distance intervals

    Geodesic Track Interpolation

    Cubic Spline Interpolation

    Custom Track Interpolation

    Choosing a method

    Where to go next

    Data Cleaning
    Linear interpolation on the vessel track with equal time intervals
    Linear interpolation with equal distance intervals
    Linear interpolation of the vessel track along the geodesic curve
    Cubic spline interpolation with equal time intervals
    B-spline interpolation with equal time intervals of 1,000 seconds

    Method gen_qry

    Generate trajectories

    Query types with practical examples

    Within time range

    Within bounding box

    Combination of multiple conditions

    Filtering MMSI

    Where to go next

    Query types with practical examples
    API-Doc
    gen_qry
    DBQuery
    TrackGen
    callbacks
    in_timerange_validmmsi
    Data Cleaning
    types of callbacks
    data-cleaning.md
    data-visualization.md
    Queried vessel tracks in specified time range
    Queried vessel tracks within a defined bounding box
    Queried vessel tracks within a defined bounding box and time range
    Queried tracks of vessels of interest within a specified time range
    Haversine Distance
    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)
    custom_interpolation.py
    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 itr
    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__ = 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 track
    Track 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.5
    import aisdb
    from datetime import datetime
    from aisdb import DomainFromPoints
    
    dbpath = 'YOUR_DATABASE.db'  # Define the 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')
    
    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_validmmsi
    import 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')

    Data Visualization

    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.

    Internal visualization

    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,
        )
    Visualizing queried vessel tracks in a single color

    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,
        )
    Visualizing vessel tracks in multiple colors based on MMSIs

    Alternative visualization

    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.

    Haversine Distance

    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.

    Using Your AIS Data

    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.

    Data Source

    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.

    Downloading and Processing Individual Files

    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:

    download.sh
    curl -o ./data/AIS_2020_01_01.zip https://coast.noaa.gov/htdata/CMSP/AISDataHandler/2020/AIS_2020_01_01.zip

    Then, 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.

    1

    Clone the repository and sync its environment with :

    2

    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).

    TimescaleDB documentation

    Contextily + Matplotlib

    ⚠️ Basemap + Matplotlib

    Cartopy

    Plotly

    Kepler.gl

    Where to go next

    tutorials/track-interpolation.md
    Visualization of vessel tracks with Contextily
    Visualization of vessel tracks with Basemap
    Visualization of vessel tracks with Cartopy
    Interactive visualization of vessel tracks with Plotly
    Interactive visualization of vessel positions with Plotly
    Interactive visualization of vessel track positions with Kepler.gl
    Heat map of vessel track density with Kepler.gl
    Distance3D=Haversine Distance2+Depth2Distance_{3D} = \sqrt{Haversine\ Distance^2 + Depth^2}Distance3D​=Haversine Distance2+Depth2​

    Distance to a submerged point

    3

    Preprocessing - Geographic Filtering and Deduplication

    The optional filter stage keeps only records inside a geographic bounding box, and the dedup stage removes duplicate rows, retaining unique AIS messages:

    4

    Database Loading

    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:

    5

    Query the loaded data

    Once loading finishes, the data is queryable with the standard AISdb interfaces (DBQuery, TrackGen) shown earlier in this tutorial, or directly with psql:

    Querying Your Own Data

    Cleaning the Data

    Processing and Exporting

    Pipeline for Bulk File Downloads and Database Integration

    Install the tool

    AIS Data Download and Extraction

    Data Querying
    Data Cleaning
    GitHub repository
    uv
    MarineCadastre
    contextily.py
    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)
    basemap.py
    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)
    cartopy.py
    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)
    plotly.py
    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')
    kepler.py
    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]
    filter_dedup.sh
    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
    load.sh
    # 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 --timescaledb
    query.sh
    psql -U USERNAME -d DBNAME -h localhost -p 5432
    extract.sh
    unzip ./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"]
    process.py
    # 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,
        )
    install.sh
    git clone https://github.com/MAPS-Lab/NOAA-Integrator
    cd NOAA-Integrator
    uv sync --extra load
    download_extract.sh
    uv 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/extracted

    Vessel Metadata

    Attach 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.

    Earlier AISdb releases shipped web scrapers for VesselFinder and MarineTraffic. Both sites now block automated access and have moved vessel particulars behind paid subscriptions, so the scraping layer no longer works and has been removed from the package. Metadata lookups against a local database still work exactly as before; to acquire new metadata, use an official API such as the MarineTraffic one shown below.

    Metadata storage

    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.

    Attaching metadata to tracks

    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.

    AIS Data to CSV

    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.

    Exporting to CSV

    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.

    Coast, shore, and ports

    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:

    Sample track created for distance to shore and port calculation

    Distance from shore, coast, and port

    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.

    Acquiring metadata (official API)

    Fetching a list of ports

    The aisdb.ports module landed after the 1.8.0-alpha release, so it is not in that tag or in the PyPI package. To use WorldPortIndexClient, install AISdb from the development branch with pip install git+https://github.com/MAPS-Lab/AISdb.git. The raster distance classes above work on 1.8.0-alpha as-is.

    aisdb.webdata.shore_dist.ShoreDist
    aisdb.webdata.shore_dist.PortDist
    hexagon discretization
    attach_metadata.py
    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'])
    marinetraffic_api.py
    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_ID
    column so you can group rows belonging to the same voyage segment back together after loading the file elsewhere.
    are dynamic arrays with one entry per AIS message, so they need to be indexed by position.
    mmsi,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,1
    export_postgresql.py
    import 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,1
    custom_csv.py
    import 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")

    Writing a Custom CSV

    export_sqlite.py
    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")

    Decimation with AISdb

    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.

    What is Decimation in the Context of AIS Tracks?

    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.

    Why Decimate AIS Data?

    There are several key benefits to using decimation when working with AIS data.

    1. 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.

    2. 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.

    3. 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.

    1. 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:

    Automatic Identification System

    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.

    xmax
    '
    ],
    ymax
    '
    ],
    AIS is standardized under ITU-R Recommendation M.1371, which defines 27 message types, numbered 1 through 27, each carrying a different kind of information in a fixed binary layout. Not every station uses every type. In practice, a handful of message types account for almost all the traffic you will see in a raw AIS feed.
    • 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.

    1. 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.

    2. 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.

    3. 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.

    4. 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.

    1. 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

    2. Kazim, T. (2016, November 14). A definitive guide to AIS. MarineLink. Retrieved May 14, 2025, from https://www.marinelink.com/news/definitive-guide-ais418266

    3. 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).

    How does this work?

    The ITU-R M.1371 message set

    Image from Marinelink

    Class A versus Class B transponders

    MMSI structure

    Dynamic versus static messages

    Limitations of AIS signals

    References

    applies the same function using your chosen precision instead of the
    0.0001
    default, giving you direct control over how aggressively each track is simplified.
    TrackGen()
    for a single track.
  • 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.

  • AISdb and TrackGen(..., decimate=...)

    How the Visvalingam-Whyatt Algorithm Works

    Using TrackGen(..., decimate=True) with AISdb Tracks

    Using simplify_linestring_idx() Directly

    Illustration of Decimation

    Key Parameters and Usage Notes

    Conclusion

    References

    Visvalingam-Whyatt algorithm
    10.1177/15501477211050729
    (Amigo et al., 2021)
    decimate_trackgen.py
    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: 27
    simplify_linestring_idx.py
    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),
            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}")

    Bathymetric Data

    Merge GEBCO global bathymetric grids onto AIS tracks with AISdb, then color and visualize vessel movement by the depth of water it traveled through.

    Process AIS data with Bathymetric Data

    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.

    1

    Downloading the GEBCO bathymetry grid

    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.

    2

    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.

    3

    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

    4

    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.

    5

    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).

    Hexagon Discretization

    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.

    block and closes them all on exit. That's also why the bathymetry lookup has to happen inside the same context manager the query and
    TrackGen
    are wrapped in, or shortly after, rather than being deferred to some later point in the script.

    Once 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).

  • Querying the AIS data

    Merging depth onto the tracks

    Coloring the tracks by depth

    Putting it together

    Vessel tracks colored with average depths relative to the bathymetry
    The 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

    1. https://www.uber.com/en-CA/blog/h3/

    Introduction

    Discretize AIS lat/lon points to hexagons using AISdb

    The aisdb.discretize.h3 module landed after the 1.8.0-alpha release, so it is not in that tag or in the PyPI package. To follow this tutorial, install AISdb from the development branch with pip install git+https://github.com/MAPS-Lab/AISdb.git (a Rust toolchain is required to build it).

    h3-py

    References

    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 track
    tracks_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])
    discretize_tracks.py
    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()

    Weather Data

    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.

    Prerequisites

    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 .

    1
    2

    WeatherDataStore doesn't care which backend your tracks came from, so a SQLiteDBConn works exactly the same way here.

    3

    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.

    Kalman Filters with FilterPy

    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,

    .
    TrackGen
    returns a generator of dictionaries, one per vessel, each holding the dynamic and static columns for that track.
    4

    Step 4: Set up WeatherDataStore

    WeatherDataStore 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.

    The development branch of AISdb extends WeatherDataStore with a download_from_cds=True flag and an area keyword ([xmin, xmax, ymin, ymax] in degrees), which request exactly the region you need from CDS instead of relying on pre-downloaded files. If you installed from the master branch rather than the 1.8.0-alpha release, that shortcut is available. CDS requests queue server-side and can take a while, so for repeated runs it is still faster to download once and reuse the files.

    5

    Step 5: Look up a single point

    If you just need weather at one latitude, longitude, and timestamp, extract_weather returns it directly without touching any tracks.

    6

    Step 6: Merge weather into the tracks

    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.

    How it fits together

    Short names

    Step 1: Import the packages you need

    Step 2: Connect to the database

    Step 3: Query the tracks

    Why bother merging weather with AIS?

    ERA5 data documentation page
    U-V 100m component wind over the Gulf of St. Lawrence for Aug 2018
    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/s
    import aisdb
    from aisdb import DBQuery
    from aisdb.database.dbconn import PostgresDBConn
    from datetime import datetime
    from aisdb.weather.data_store import WeatherDataStore
    db_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)
    R
    ,
    P
    ,
    F
    , and
    Q
  • Handle 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.

    Kalman speed estimate against the AIS-reported SOG for MMSI 538090531. After the spin-up the estimate locks onto the ~10.5 knot cruise, follows the drop to a four-hour stop around hour 14, and picks the vessel back up as it gets underway again near hour 19.

    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.

    Raw AIS positions versus the Kalman-smoothed track for MMSI 538090531, a 200 km transit across the Gulf on 2020-01-01. At basin scale the smoothed line sits on top of the raw fixes; the hook at the northwest end is the anchoring stop, where the filter rounds the swing instead of chasing every jittered fix.

    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

    What you will learn

    FilterPy
    pip install aisdb filterpy matplotlib
    load_tracks.py
    from 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)
    select_steady_track.py
    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 reports
    kalman_filter.py
    from 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 Q
    run_kalman_filter.py
    smoothed_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 + lat0
    kalman_flag_outliers.py
    flagged = []
    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 outliers
    plot_results.py
    import 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()

    Prerequisites

    1. Query the day and generate tracks

    2. Pick one steady vessel

    3. Build the constant-velocity filter

    4. Run the filter over the track

    5. Flag GPS outliers with the innovation

    Results

    Takeaway

    References

    Clustering with Scikit Learn

    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.

    What you will learn

    • 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 matplotlib
    load_tracks.py
    import 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)
    dbscan_hotspots.py
    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 points
    track_features.py
    MAX_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 check
    kmeans_sweep.py
    from 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}
    plot_clusters.py
    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()

    Prerequisites

    1. Decode the day file and generate tracks

    2. Find spatial hotspots with DBSCAN

    3. Build per-track movement features

    4. Sweep k, fit KMeans, read the profiles

    Results

    Takeaway

    References

    AIS_2020_01_01.zip
    Data Cleaning
    Kalman Filters with FilterPy
    https://scikit-learn.org/stable/modules/clustering.html
    https://github.com/MAPS-Lab/AISdb
    DBSCAN hotspots over the Gulf of Mexico, one NOAA day subsampled to 200,000 positions. The 26,630 noise points render in one color and trace the offshore fairways; the 555 dense clusters (eps 1 km, min_samples 25) pile up along the coast at ports, anchorages, and platforms.
    KMeans behavior groups, same day and region. The silhouette sweep selects k=2, splitting 814 underway transits (blue) whose paths trace the lanes across the Gulf from 5052 near-stationary moored and anchored tracks (orange) hugging the ports and coast.

    AutoEncoders in Keras

    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.

    What you will learn

    • 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.

    From here, tune the layer widths and the mixing probability with a search library such as HyperOpt, and probe the trained model with permutation feature importance or UMAP projections of its latent space.
    pip install aisdb tensorflow geopandas shapely plotly wandb scikit-learn tqdm
    import 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_data
    tf.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)

    Prerequisites

    Step 1. Set up imports and working folders

    Step 2. Query and segment a year of AIS

    Step 3. Drop tracks that touch land

    Step 4. Split voyages into train and test

    Step 5. Window trajectories into samples

    Step 6. Normalize the samples

    Step 7. Build the teacher-forcing GRU autoencoder

    Step 8. Compile with a haversine loss

    Model summary output (Training graph and Inference graph, full layer listing)

    Step 9. Train with callbacks

    Step 10. Evaluate in meters

    Results

    Takeaway

    AIS_2020_01_01.zip
    Using Your AIS Data
    arXiv:1406.1078
    Embedding with traj2vec
    100 km AIS receiver coverage over Atlantic Canada, from the private regional database used in this tutorial (full year 2021).
    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
    __________________________________________________________________________________________________

    Using Newtonian PINNs

    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.

    What you will learn

    • 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.

    Embedding with traj2vec

    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.

    What you will learn

    • 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.

    Every vessel is forecast from its own history alone, so converging traffic is invisible to the model.
    pip install aisdb torch scikit-learn pyproj joblib pandas cartopy matplotlib
    import 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_scaler
    def 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 m
    Trajectory length (true): 188.76 m | pred: 206.01 m | diff: 17.25 m

    Prerequisites

    Step 1. Imports and configuration

    Step 2. Preprocess tracks into model-ready features

    Step 3. Query, build sequences, and checkpoint

    Step 4. Define the Seq2Seq model

    Step 5. Define the NPINN loss and training loop

    Step 6. Train

    Step 7. Reload the best model and define evaluation helpers

    Step 8. Evaluate on the held-out window

    Results

    Takeaway

    coast.noaa.gov
    Raissi et al., 2019
    TGNs with TorchGeometric
    Predicted (red) vs. true (green) vessel trajectory 2, Gulf of St. Lawrence test window, August 2018.
    Predicted (red) vs. true (green) vessel trajectory 5, Gulf of St. Lawrence test window, August 2018.
    pip install aisdb torch h3 geopandas cartopy matplotlib seaborn scikit-learn tqdm nest_asyncio
    import 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)

    Prerequisites

    Step 1. Query and clean the tracks

    Step 2. Tokenize tracks onto the H3 grid

    Step 3. Inspect and filter track lengths

    Step 4. Build the H3 vocabulary

    Step 5. Split and write the dataset

    Step 6. Train and evaluate

    Results

    Test-set log: cumulative genloss per iteration and final perplexity

    Takeaway

    coast.noaa.gov
    github.com/boathit/t2vec
    Using Newtonian PINNs
    Raw AIS trajectories over the Gulf of St. Lawrence approaches and the Nova Scotia coastline, before H3 discretization or length filtering, private PostgreSQL corpus (January 2023).
    Distribution of AIS track lengths in H3 cells before filtering, private PostgreSQL corpus (January 2023). Most tracks span fewer than 20 cells, with a long tail running out to several hundred.
    Distribution of AIS track lengths in H3 cells after filtering to the 10-300 range, private PostgreSQL corpus (January 2023).
    Held-out test trajectories in raw lat/lon space after H3 tokenization and the train/validation/test split, private PostgreSQL corpus (January 2023).
    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.26

    seq2seq in PyTorch

    Build 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.

    What you will learn

    • 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)

    t
    lon_true
    lon_pred
    lat_true
    lat_pred
    Error (in m)

    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.

    A No-Code Interface

    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

    Prerequisites

    Step 1. Query training and test tracks

    Step 2. Preprocess the tracks

    Step 3. Create sequences and loaders

    Step 4. Define the model and losses

    Step 5. Train

    Step 6. Evaluate on held-out tracks

    Results

    Takeaway

    AIS_2020_01_01.zip
    Using Your AIS Data
    Bahdanau et al., 2015
    Sutskever et al., 2014
    AutoEncoders in Keras
    Query bounding box over the Gulf of St. Lawrence extract, 2018-08-01 to 2018-08-04.
    Predicted vs. true two-step continuation for a held-out track, Gulf of St. Lawrence extract, test window 2018-08-05 to 2018-08-06.

    43.22816

    pip install aisdb torch scikit-learn pandas numpy pyproj cartopy matplotlib
    import 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.5
    def 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)
    Enrich tracks with bathymetry and weather context
  • 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 landing page after a CSV upload, showing the automatic row count and per-column summary for the loaded dataset.

    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.

    1. Split by time breaks continuous tracks into segments at significant time gaps.

    2. Encode track applies distance and speed thresholds to smooth and segment trajectories.

    3. Discretize H3 converts coordinates into H3 hexagonal grid cells for spatial analysis.

    4. Detect stops finds stopping periods from speed and duration thresholds.

    5. Add bathymetry samples water depth along each track from a GeoTIFF.

    6. 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/

    What you will learn

    pip install aisdb gradio pandas numpy rasterio plotly matplotlib cartopy h3 langchain-google-genai google-generativeai
    export GOOGLE_API_KEY="your-key-here"
    python ais_bot.py

    Prerequisites

    1. Download and launch the app

    2. Load a CSV

    3. Chain preprocessing steps

    4. Plot the processed tracks

    5. Ask questions about the data

    Results

    Takeaway

    References

    ais_bot.py
    49KB
    Open

    TGNs with TorchGeometric

    Turn 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.

    What you will learn

    • 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.

    Building a RAG Chatbot

    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.

    What you will learn

    • 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.

    instead. A chronological split holds out the last 30% of events so training never peeks at the future.

    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 pandas
    from 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.7728

    Prerequisites

    Step 1. Decode the NOAA day file

    Step 2. Build the transit graph

    Step 3. Load the edges into TemporalData

    Step 4. Define the TGN

    Step 5. Train and evaluate

    Results

    Takeaway

    coast.noaa.gov
    arXiv:2006.10637
    Building a RAG Chatbot
    The temporal graph edge stream, one point per zone-to-zone transit against time. Several transits touch the Z0 open-water node, which sits between the channel zones. NOAA Gulf of Mexico traffic, Houston Ship Channel, 2020-01-01.
    Training loss and validation average precision across 30 epochs (learning rate 1e-3, batch size 32, seed 42). NOAA Gulf of Mexico traffic, Houston Ship Channel, 2020-01-01.

    pip install langchain "langchain[google-genai]" chromadb sentence-transformers gradio beautifulsoup4 requests
    export GOOGLE_API_KEY="your-api-key-here"
    index_docs.py
    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")
    index_docs.py
    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")
    index_docs.py
    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")
    rag_answer.py
    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"])
    gradio_app.py
    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']

    Prerequisites

    1. Scrape the documentation

    2. Split pages into chunks

    3. Embed and store in ChromaDB

    4. Retrieve context and generate answers

    5. Serve it with Gradio

    6. Publish as a Hugging Face Space

    Results

    Takeaway

    References

    https://aisviz.gitbook.io/documentation
    https://aisviz.cs.dal.ca
    Hugging Face Spaces documentation
    A No-Code Interface
    LangChain documentation
    ChromaDB documentation
    Sentence-Transformers documentation
    Google AI Studio (Gemini API keys)
    Gradio documentation
    Hexagon Discretization