
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.
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
anopticon-run run positions.csv --out findings/ --alpha 1e-4Each 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 it | Read as |
|---|---|
.csv .tsv | A table with a header row; comma, tab or semicolon |
.parquet | A table; columns found by name |
| a folder | Every .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.
| Field | CSV and TSV headers | Parquet columns |
|---|---|---|
| MMSI required | mmsi, userid, user_id, mmsi_number, ship_mmsi | mmsi, MMSI |
| Time required | received_at, basedatetime, # timestamp, timestamp, time, datetime, date_time, time_utc, timestamp_utc, msgtime, ts_pos_utc, t | received_at, time, timestamp |
| Latitude required | latitude, lat, y | latitude, lat |
| Longitude required | longitude, lon, long, lng, x | longitude, lon |
| Speed (knots) | sog_knots, sog, speed, speed_knots, speedoverground | sog_knots, sog |
| Course | cog_deg, cog, course, courseoverground | cog_deg, cog |
| Heading | true_heading_deg, heading, trueheading, true_heading | true_heading_deg, heading |
| Rate of turn | rate_of_turn, rot, rateofturn | rate_of_turn, rot |
| Navigational status | navigational_status, nav_status, status, navstat, navigationalstatus | navigational_status, nav_status |
| AIS time stamp (the second field) | ais_second, second, utc_second, timestamp_second | ais_second |
| Message type or class | message_type, msgtype, msg_type, messagetype, transceiverclass, transceiver_class, class, type_of_mobile | message_type |
| Ship type | ship_type, vesseltype, vessel_type, shiptype, type | ship_type |
| Length, beam, draught (m) | length_m, length, loa / beam_m, width, beam / max_draught, draught, draft, draught_m | length_m / beam_m / max_draught, draught_m |
| IMO | imo_number, imo | imo_number, imo |
| Feed | source, feed, provider | source, 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
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:
anopticon-run run positions.csv --static ships.parquet --out findings/What a run writes
| File | Holds |
|---|---|
episodes.jsonl | One line per episode: one ship, from its last good position to its first good one after |
events.jsonl | One line per event: several unrelated ships displaced together |
offenders.jsonl | Ships whose own track is unusual all the time |
summary.json | Records 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
| status | Means |
|---|---|
proven | No 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 |
anomalous | Unusual 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
{
"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."
}| Field | Meaning |
|---|---|
state | open (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, basis | How sure (How sure). basis is null unless proven |
urgency | immediate when proven on physics and not an identity conflict; otherwise routine |
category, cause | What caused it (Causes) |
event_id | The event it belongs to, if any |
since, until | First and last flagged report. until is null until the episode closes |
last_good_fix | Where the ship last reported truthfully before the episode |
first_good_fix_after | Its first good report after it |
false_positions | How many reports were flagged, and the area they fall in |
proof | Each proof, with its arithmetic (Proofs) |
evidence | The checks that flagged it, and on how many reports |
min_p | The smallest p-value among its reports: compare it with the alarm rate |
feeds | The feeds its flagged reports came from |
coverage | Reports judged in the episode, flagged, and proven |
persistent_offender | The ship is a persistent offender (Persistent offenders) |
summary | One 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.
| kind | Means |
|---|---|
unreachable_step | From one report to the next faster than the hull can go |
consecutive_unreachable_steps | Several such steps in a row |
unreachable_jump_that_stays | An unreachable jump, and the ship stays at the new place |
reports_beyond_reach_that_come_back | Reports beyond reach that return to the track |
arrived_on_land_by_unreachable_step | On land, reached by a step no hull can make |
walking_overland_at_zero_speed | Moving over land while reporting no speed |
loop_faster_than_any_hull | A track that repeats itself faster than any hull |
circle_faster_than_any_hull | Circling faster than any hull |
one_impossible_speed_shared_by_unrelated_hulls | Several unrelated ships nearby report the same speed, beyond all their hulls |
Causes
| cause | category | Means |
|---|---|---|
area_spoofing | position_falsified | Its false positions belong to an event: several unrelated ships moved the same way |
self_spoofing | position_falsified | This ship’s positions alone, proven false |
shared_mmsi | identity_conflict | Two transmitters use this number |
receiver_fault | data_fault | On a retracted episode: one report off the track and back, from a receiver or feed |
undetermined | undetermined | Anomalous, 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
{
"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."
}| Field | Meaning |
|---|---|
state | open or ended (no member flagged for a while) |
moved_to | Where the members’ false positions are |
ships_were | Where the members last reported truthfully |
displacement_km_median | The median distance between the two, per member |
shared_speed_knots | One impossible speed the members share, if any |
ships | Ships heard in the area, ships in the event, ships proven |
members | Their 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.
{
"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:
| Reason | The report |
|---|---|
fixed_station | Is not from a ship: a base station, an aid to navigation, an aircraft, a group number |
no_position | Has latitude 91 or longitude 181 (not available), or a position off the globe |
null_island | Is an empty fix near 0, 0 |
fix_inoperative | Says its positioning system is inoperative |
feed_replay | Repeats an earlier message of the same ship, arriving again |
duplicate | Was already delivered by another feed |
clock_lead | Is stamped later than it arrived |
receiver_fault_step | Was garbled by the receiver: one coordinate frozen or corrupted, or rounded to whole arc-minutes |
Acting on findings
| When | Do |
|---|---|
proven, immediate | The 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, proven | Positions in moved_to are being falsified for every ship there. members lists them; more may join while it is open |
shared_mmsi | Two transmitters on one number. Check the identity before you trust its track |
anomalous | Look at it. The smaller min_p, the stranger |
retracted | Withdraw what you raised on an earlier revision of the same id; summary says why |
"persistent_offender": true | Its 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.
| --alpha | In words |
|---|---|
1e-3 | one in a thousand |
1e-4 | one in ten thousand |
2e-5 | one in fifty thousand; the default |
1e-6 | one in a million: proofs, almost only |
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 DIR | Where the findings go. Default out |
--alpha P | The alarm rate. Default 2e-5 |
--also-alpha P,P | More alarm rates to count in summary.json |
--static FILE | Ship data, Parquet. Repeat for several files |
--only MMSI,MMSI | Judge 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.
