Painting: a geographer at a window leans over a chart with a pair of dividers, a globe on the cupboard behind him.

Docs

Ananke, pre-beta.

The Geographer, Johannes Vermeer, 1669, Städel Museum

Pre-beta This page describes the engine as it runs today.

Overview

Ananke reads ship position reports (AIS) and holds each one to the same ship’s earlier reports and to what its hull and the sea allow. It runs on your machine, reads your files and writes its findings to a folder you name. It opens no network connection.

Your AIS files→Ananke→episodes, events, offenders, summary

A finding is about a ship or an area, not a single report: an episode is one ship from its last good position to its first good one after; an event is several unrelated ships displaced together.

Run it

One file
anopticon-run run positions.csv --out findings/ --alpha 1e-4

Each argument before the options is a feed: a file or a folder. The findings go to the folder --out names (out/ if you leave it out). --alpha sets the alarm rate (Alarm rate).

The command, its calibration and its maps come as one folder. Run it from there.

Files

Give itRead as
.csv .tsvA table with a header row; comma, tab or semicolon
.parquetA table; columns found by name
a folderEvery .csv, .tsv and .parquet file in it and below, in name order

Files need not be in time order. MarineCadastre and Danish Maritime Authority exports read as they are. NMEA files and live feeds are not read yet.

Columns

MMSI, time, latitude and longitude are required. Every other column lets more checks run. CSV headers are matched without regard to case, with spaces and hyphens read as underscores.

FieldCSV and TSV headersParquet columns
MMSI requiredmmsi, userid, user_id, mmsi_number, ship_mmsimmsi, MMSI
Time requiredreceived_at, basedatetime, # timestamp, timestamp, time, datetime, date_time, time_utc, timestamp_utc, msgtime, ts_pos_utc, treceived_at, time, timestamp
Latitude requiredlatitude, lat, ylatitude, lat
Longitude requiredlongitude, lon, long, lng, xlongitude, lon
Speed (knots)sog_knots, sog, speed, speed_knots, speedovergroundsog_knots, sog
Coursecog_deg, cog, course, courseovergroundcog_deg, cog
Headingtrue_heading_deg, heading, trueheading, true_headingtrue_heading_deg, heading
Rate of turnrate_of_turn, rot, rateofturnrate_of_turn, rot
Navigational statusnavigational_status, nav_status, status, navstat, navigationalstatusnavigational_status, nav_status
AIS time stamp (the second field)ais_second, second, utc_second, timestamp_secondais_second
Message type or classmessage_type, msgtype, msg_type, messagetype, transceiverclass, transceiver_class, class, type_of_mobilemessage_type
Ship typeship_type, vesseltype, vessel_type, shiptype, typeship_type
Length, beam, draught (m)length_m, length, loa / beam_m, width, beam / max_draught, draught, draft, draught_mlength_m / beam_m / max_draught, draught_m
IMOimo_number, imoimo_number, imo
Feedsource, feed, providersource, feed

Times: seconds, milliseconds or microseconds since 1970, ISO 8601, or day/month/year as the Danish Maritime Authority writes it. A time with no zone is read as UTC, and summary.json says so under assumed. AIS “not available” values (heading 511, course 360, speed 102.3) read as missing. Rows without an MMSI or a readable time are skipped.

Several feeds

bash
anopticon-run run terrestrial.csv satellite.parquet --out findings/

Name several files to judge them as one picture. Each file is a feed, named after the file; a table with a source, feed or provider column becomes one feed per value. One transmission heard on two feeds is judged once, and each finding names the feeds it rests on.

Ship data

Ship type, length, beam, draught and IMO sharpen the hull checks. Put them in the position file’s columns, or pass a Parquet file with any of mmsi, ship_type, length_m, beam_m, max_draught, imo_number:

bash
anopticon-run run positions.csv --static ships.parquet --out findings/

What a run writes

FileHolds
episodes.jsonlOne line per episode: one ship, from its last good position to its first good one after
events.jsonlOne line per event: several unrelated ships displaced together
offenders.jsonlShips whose own track is unusual all the time
summary.jsonRecords read, set aside and judged; episodes at each alarm rate; the feeds; what was assumed

Each line is one JSON object with "schema": "ananke/1". A finding is written again when it changes: keep the line with the highest revision for each id.

How sure

statusMeans
provenNo hull can do it. basis says on what: physics (positions no hull could reach) or identity (one number on two transmitters at once). The alarm rate does not move it
anomalousUnusual at the alarm rate you set. min_p is how unusual: the smaller, the stranger

Reports with nothing found make no output. Reports that cannot be judged are set aside before any check and counted in summary.json (Set aside).

Episodes

Example
{
  "schema": "ananke/1",
  "kind": "episode",
  "id": "ep_231",
  "revision": 1,
  "state": "closed",
  "engine": "0.1.0",
  "calibration": "cal-2026.11.0",
  "mmsi": 273000000,
  "imo": null,
  "status": "proven",
  "basis": "physics",
  "urgency": "immediate",
  "category": "position_falsified",
  "cause": "self_spoofing",
  "event_id": null,
  "since": "2026-10-20T09:31:25Z",
  "until": "2026-10-20T09:43:25Z",
  "last_good_fix": {
    "time": "2026-10-20T09:22:23Z",
    "lat": 61.09597,
    "lon": 28.94465
  },
  "first_good_fix_after": {
    "time": "2026-10-20T10:13:22Z",
    "lat": 60.00923,
    "lon": 28.47036
  },
  "false_positions": {
    "count": 3,
    "area": {
      "lat": 60.3716,
      "lon": 28.6286,
      "radius_km": 82.4
    }
  },
  "proof": [
    {
      "producer": "simultaneous_presence",
      "basis": "physics",
      "kind": "unreachable_step",
      "metres": 123572.6,
      "seconds": 541.6,
      "knots": 443.5,
      "limit_knots": 50.0
    }
  ],
  "evidence": [
    {
      "check": "dr_envelope",
      "reports": 2
    }
  ],
  "min_p": 0.0000584,
  "feeds": [
    "digitraffic"
  ],
  "coverage": {
    "reports": 8,
    "flagged": 3,
    "proven": 1
  },
  "persistent_offender": false,
  "summary": "Reported positions no hull could reach (3 of 8 reports), around 60.372, 28.629, 82 km from its last good fix, reached at 444 kn against a limit of 50 kn, from 09:31 UTC to 09:43 UTC."
}
FieldMeaning
stateopen (still open when the input ended), closed (the ship is back on a good track, or fell silent) or retracted (explained away; summary says how)
status, basisHow sure (How sure). basis is null unless proven
urgencyimmediate when proven on physics and not an identity conflict; otherwise routine
category, causeWhat caused it (Causes)
event_idThe event it belongs to, if any
since, untilFirst and last flagged report. until is null until the episode closes
last_good_fixWhere the ship last reported truthfully before the episode
first_good_fix_afterIts first good report after it
false_positionsHow many reports were flagged, and the area they fall in
proofEach proof, with its arithmetic (Proofs)
evidenceThe checks that flagged it, and on how many reports
min_pThe smallest p-value among its reports: compare it with the alarm rate
feedsThe feeds its flagged reports came from
coverageReports judged in the episode, flagged, and proven
persistent_offenderThe ship is a persistent offender (Persistent offenders)
summaryOne sentence built from the numbers above

Proofs

Each proof carries the numbers to check it by hand: metres, seconds, knots (the speed the reports imply), limit_knots (the fastest the hull can go) and inland_m (distance from any water), whichever apply. producer names the check that made it.

kindMeans
unreachable_stepFrom one report to the next faster than the hull can go
consecutive_unreachable_stepsSeveral such steps in a row
unreachable_jump_that_staysAn unreachable jump, and the ship stays at the new place
reports_beyond_reach_that_come_backReports beyond reach that return to the track
arrived_on_land_by_unreachable_stepOn land, reached by a step no hull can make
walking_overland_at_zero_speedMoving over land while reporting no speed
loop_faster_than_any_hullA track that repeats itself faster than any hull
circle_faster_than_any_hullCircling faster than any hull
one_impossible_speed_shared_by_unrelated_hullsSeveral unrelated ships nearby report the same speed, beyond all their hulls

Causes

causecategoryMeans
area_spoofingposition_falsifiedIts false positions belong to an event: several unrelated ships moved the same way
self_spoofingposition_falsifiedThis ship’s positions alone, proven false
shared_mmsiidentity_conflictTwo transmitters use this number
receiver_faultdata_faultOn a retracted episode: one report off the track and back, from a receiver or feed
undeterminedundeterminedAnomalous, and none of the above holds

An episode first sent with one cause can be sent again with another as more reports arrive: a number later found shared is re-sent as shared_mmsi.

Events

Example
{
  "schema": "ananke/1",
  "kind": "event",
  "id": "ev_577",
  "revision": 12,
  "state": "open",
  "engine": "0.1.0",
  "calibration": "cal-2026.11.0",
  "status": "proven",
  "basis": "physics",
  "urgency": "immediate",
  "category": "position_falsified",
  "cause": "area_spoofing",
  "since": "2026-10-20T10:45:57Z",
  "until": null,
  "moved_to": {
    "lat": 54.0946,
    "lon": 38.2307,
    "radius_km": 0.6
  },
  "ships_were": {
    "lat": 55.9963,
    "lon": 34.9552,
    "radius_km": 576.4
  },
  "displacement_km_median": 0.6,
  "shared_speed_knots": 54.0,
  "ships": {
    "heard_in_area": 16,
    "in_event": 3,
    "proven": 3
  },
  "members": [
    273000001,
    273000002,
    273000003
  ],
  "summary": "3 ships (3 proven) reported positions within 1 km of 54.095, 38.231, reporting one speed no hull of theirs can make (54 kn), between 10:45 and 22:23 UTC; 16 ships were heard in the area."
}
FieldMeaning
stateopen or ended (no member flagged for a while)
moved_toWhere the members’ false positions are
ships_wereWhere the members last reported truthfully
displacement_km_medianThe median distance between the two, per member
shared_speed_knotsOne impossible speed the members share, if any
shipsShips heard in the area, ships in the event, ships proven
membersTheir MMSIs. Each member’s episode names the event in event_id

Persistent offenders

A few ships break the ordinary motion line far more often than ships do: an odd transmitter or installation. Once a ship has shown this over enough reports it is a persistent offender. From then on its unusual motion raises no alert, since it is its normal; its proofs still stand. Its episodes say "persistent_offender": true.

Example
{
  "schema": "ananke/1",
  "kind": "persistent_offender",
  "mmsi": 230000000,
  "since": "2026-10-20T07:14:02Z",
  "steps": 412,
  "breaches_99": 61,
  "breach_share": 0.148,
  "meaning": "this ship's own track breaks the 99 % motion line far more often than ships do; its envelope and CUSUM findings are not alerts, its proofs are"
}

steps is how many of its reports were judged, breaches_99 how many broke the line, and breach_share the share. Join the list to your own vessel records.

Set aside

Reports set aside are never judged and never prove anything. summary.json counts them by reason under stats.set_aside:

ReasonThe report
fixed_stationIs not from a ship: a base station, an aid to navigation, an aircraft, a group number
no_positionHas latitude 91 or longitude 181 (not available), or a position off the globe
null_islandIs an empty fix near 0, 0
fix_inoperativeSays its positioning system is inoperative
feed_replayRepeats an earlier message of the same ship, arriving again
duplicateWas already delivered by another feed
clock_leadIs stamped later than it arrived
receiver_fault_stepWas garbled by the receiver: one coordinate frozen or corrupted, or rounded to whole arc-minutes

Acting on findings

WhenDo
proven, immediateThe positions are false. Treat the ship as not where it says from since to until; last_good_fix is where it last was
an event, provenPositions in moved_to are being falsified for every ship there. members lists them; more may join while it is open
shared_mmsiTwo transmitters on one number. Check the identity before you trust its track
anomalousLook at it. The smaller min_p, the stranger
retractedWithdraw what you raised on an earlier revision of the same id; summary says why
"persistent_offender": trueIts unusual motion is already muted; its proofs still stand

Route on urgency, category and status; they are a short fixed list. Read a value you do not recognise as routine.

Alarm rate

--alpha sets how unusual a report must be to be anomalous: the chance that ordinary traffic looks at least this unusual on one judged report. Smaller is stricter: fewer alerts, fewer catches. proven ignores it.

--alphaIn words
1e-3one in a thousand
1e-4one in ten thousand
2e-5one in fifty thousand; the default
1e-6one in a million: proofs, almost only
Compare settings in one run
anopticon-run run positions.csv --alpha 1e-4 --also-alpha 1e-3,1e-6 --out findings/

--also-alpha adds a row per setting to dial in summary.json: episodes, proven, anomalous, and episodes per 1,000 ship-days. To be stricter after a run, keep the anomalous episodes whose min_p is at or below the new setting. To be looser, run again.

Command line

anopticon-run run <feed>... [--out DIR] [--alpha P] [--also-alpha P,P] [--static FILE] [--only MMSI,MMSI]

Judge the feeds and write the findings to --out.

Option
<feed>A file or a folder. Several feeds are judged together
--out DIRWhere the findings go. Default out
--alpha PThe alarm rate. Default 2e-5
--also-alpha P,PMore alarm rates to count in summary.json
--static FILEShip data, Parquet. Repeat for several files
--only MMSI,MMSIJudge only these ships

Calibration and maps

Ananke judges with one calibration, the same for every customer; nothing you feed it changes it. Every output names it in calibration, with the engine version in engine.

Land comes from Natural Earth. Inland water, docks and harbour basins come from OpenStreetMap (© OpenStreetMap contributors, ODbL).

Status

Pre-beta. It reads CSV, TSV and Parquet files. NMEA files and live feeds are not read yet. A changed output field is a new schema version.