Receiver trust and maritimeEdit on GitHubSource: docs/INTERFERENCE-MAP.md

Interference map and route exposure

kshana interference-map builds a per-day picture of where aircraft and ships reported degraded navigation data, from openly licensed tracking files you supply. kshana route-exposure then says how much of a planned route lay inside the affected cells on each day.

What this is: a description of what published position reports looked like on past days. What it is not: a measurement of any receiver, a forecast, or proof of interference. A degraded or anomalous cell has several possible causes (listed below), and a cell with no entry was simply not observed.

Everything runs on local files. The default commands never open a network connection; the single optional download helper needs an explicit flag.

Two synthetic interference-map days drawn from the committed samples in examples/interference-map/output, each with its own synthetic route and the route-exposure shares the engine reports. Left, an ADS-B day: 1 degraded cell, 6 not degraded, 4 unassessed (hatched) and cells with no entry, drawn empty, which were not observed. The route is 525 km: 11.5% of its length in degraded cells, 38.3% not degraded, 9.1% unassessed and 41.1% not observed. Right, an AIS day: cells flagged by the circle and on-land detectors, not-anomalous cells and empty cells; the route is 454 km with 10.5% in anomalous cells, 21.2% not anomalous, 0.0% unassessed and 68.3% not observed. ADS-B and AIS are separate layers, never combined. A flagged cell is not a finding of interference, and a cell with no colour was not observed, which is not the same as clear. All data are made up, in the open mid-Atlantic, and not a measurement.Two synthetic interference-map days drawn from the committed samples in examples/interference-map/output, each with its own synthetic route and the route-exposure shares the engine reports. Left, an ADS-B day: 1 degraded cell, 6 not degraded, 4 unassessed (hatched) and cells with no entry, drawn empty, which were not observed. The route is 525 km: 11.5% of its length in degraded cells, 38.3% not degraded, 9.1% unassessed and 41.1% not observed. Right, an AIS day: cells flagged by the circle and on-land detectors, not-anomalous cells and empty cells; the route is 454 km with 10.5% in anomalous cells, 21.2% not anomalous, 0.0% unassessed and 68.3% not observed. ADS-B and AIS are separate layers, never combined. A flagged cell is not a finding of interference, and a cell with no colour was not observed, which is not the same as clear. All data are made up, in the open mid-Atlantic, and not a measurement.

Both days are the synthetic samples in examples/interference-map/output/, each with a synthetic route in examples/interference-map/input/; the route-exposure shares are what kshana route-exposure prints for them. Nothing in them is a real place, aircraft or vessel.

Data and licences#

Only the sources reviewed in data/INTERFERENCE-DATA-SOURCES.md are built in, selected with --dataset:

--dataset Kind Licence Output licence field
adsb-lol ADS-B ODbL 1.0 ODbL-1.0
noaa-marinecadastre AIS US public domain (CC0 1.0 per the publisher) public domain
kystverket AIS NLOD 2.0 NLOD-2.0
custom either whatever you state --licence, --licence-url, --attribution required

Every output file embeds the licence, the licence URL, the required attribution and the source's coverage limits. There is no built-in entry for sources whose terms forbid commercial use or redistribution of derived maps.

ODbL and Kshana editions. A per-cell ADS-B file made from adsb.lol data is a Derived Database and is released under ODbL 1.0; its licence field says so. Kshana's own code stays AGPL-3.0. Commercial editions of Kshana that write such files carry the same notice, and anyone who republishes the file must keep it. ADS-B and AIS results are never combined in one file, so the ODbL does not attach to the AIS layer.

Coverage bias. Maps inherit the coverage of their source. adsb.lol follows volunteer receivers, so open sea and thinly covered regions are sparse. Kystverket's open data omits fishing vessels under 15 m and recreational craft under 45 m, so its map reflects larger ships. MarineCadastre covers US waters only.

Identifiers. Input rows carry an aircraft or vessel identifier. It is hashed in memory with a random per-run salt, used only to count distinct aircraft or vessels, and never written, logged or printed. Outputs are aggregate: no identifier appears anywhere in them.

Suppression. A cell with fewer than 5 distinct aircraft or vessels observed that day is not published at all, and for AIS a flagged count of one or two in a published cell is reported as null. No published value can single out an aircraft or a vessel.

Input formats#

Plain CSV with a header row, comma-separated, no quoting, UTC times. Column order is free; unknown columns are ignored. A timestamp is Unix seconds or YYYY-MM-DDTHH:MM:SS[.fff]Z. Rows that cannot be parsed are counted in the output metadata, not silently dropped.

ADS-B (kshana interference-map adsb):

Column Required Meaning
timestamp yes report time
aircraft_id yes opaque identifier (hashed, never stored)
lat, lon yes degrees
alt_baro_ft yes altitude in feet; ground or empty excludes the row
nic, nacp at least one integers 0 to 11; empty means not reported
source_type no if present, only values starting adsb are used (MLAT, TIS-B and the like are excluded)

adsb.lol readsb history, read directly. The daily archives are a split tar of readsb trace files. Extract one as the publisher describes (concatenate the parts, tar -xf -), then give Kshana the extracted directory, a single trace_full_*.json file, or a CSV:

kshana interference-map adsb extracted-day/ --dataset adsb-lol --out maps/

A directory is searched recursively for trace_full_* files (other files, such as trace_recent_*, are ignored and symbolic links are not followed). Each file is gzip-compressed or plain JSON, detected by its first bytes, because readsb writes gzip under a .json name; decompression is capped at 512 MiB per file. From each trace entry Kshana reads the time offset, latitude, longitude, altitude (the ground marker excludes the entry), the nic and nac_p fields of the aircraft details object, and the position source string (entries from anything other than ADS-B are excluded). An entry with no details object has no accuracy fields and is counted as excluded. A file that cannot be read is counted in trace_files_unreadable in the output metadata and skipped.

AIS (kshana interference-map ais):

Column Required Meaning
timestamp yes report time
vessel_id yes opaque identifier (hashed, never stored)
lat, lon yes degrees; the AIS "not available" values and (0, 0) are discarded
sog_kn no speed over ground in knots; 102.3 (not available) is treated as missing

The on-land detector needs a land polygon file (--land): GeoJSON Polygon or MultiPolygon. Natural Earth's 1:10 million land layer (public domain) is the intended file. Without --land the detector is off and the output says so. To download it (the only network access in this feature):

kshana interference-map fetch-land --out ne_10m_land.geojson --allow-network

This runs the system curl (as an argument list, not through a shell) over HTTPS, once, for one file at a fixed address that names a single commit of the upstream repository. The download is kept only if its SHA-256 equals the value pinned in the code; a mismatch deletes it and fails. If curl is not installed the command says so and gives the address and the checksum so the file can be fetched by hand. Nothing is bundled in the repository.

Output#

One GeoJSON file per source per UTC day, in the --out directory, named <source>-<dataset>-<YYYY-MM-DD>.geojson, where <source> is adsb or ais, <dataset> is the --dataset key (adsb-lol, noaa-marinecadastre, kystverket, or custom) and the date is the UTC day of the reports: adsb-adsb-lol-2026-03-01.geojson, ais-kystverket-2026-03-01.geojson. The dataset is in the name so two datasets of one kind cannot share a file, and Kshana never overwrites a file: if any target exists the command fails before writing anything. The CLI prints one line per file it writes. A reader can recover the three parts with ^(adsb|ais)-(.+)-(\d{4}-\d{2}-\d{2})\.geojson$; the same information is inside the file (source_kind, data.dataset, date). Synthetic sample files, one per source, are in examples/interference-map/output/, with the command that regenerates them, for building and testing a viewer. A top-level kshana_interference_map member holds the schema name, date, grid, method id and every parameter, the day-level figures, the data licence and attribution, and a notice. Each feature is a cell polygon with cell_i, cell_j, status, degraded and the aggregate counts.

The grid is fixed: square cells of --cell-deg degrees (default 0.5) in latitude and longitude, indexed from (-90, -180). East-west width shrinks with latitude: about 55 km at the equator and about 28 km at 60 degrees for the default. Latitude 90 belongs to the last row and longitude 180 is the same meridian as -180. The pre-registered methods are defined on the 0.5 degree grid. Another --cell-deg runs, but is not the pre-registered method: the CLI says so, and the file records grid.preregistered_cell_deg and grid.is_preregistered (false) so a viewer can say it too.

Withheld counts. Every per-cell count below the publication minimum (5) is null, zero included, so null never means "none" and no published number can single out an aircraft or a vessel. The status and degraded fields are still given. In ADS-B files aircraft_observed is always at least 5 for a published cell; aircraft_sampled and aircraft_affected are null below 5, and affected_share is null unless both are at least 5 (a share would reveal a withheld count). In AIS files each entry of vessels_flagged is null below 5.

Format version and fields#

The format is settled as schema: "kshana-interference-map/v1", format_version: 1. A change that removes or renames a field, or changes what one means, raises the version; adding a field does not, so a reader must ignore fields it does not know. A count that is null is withheld, not zero.

FeatureCollection
  kshana_interference_map
    schema, format_version, kshana_version, notice
    source_kind            "adsb" | "ais"
    date                   "YYYY-MM-DD" (UTC)
    grid                   { type: "fixed_lat_lon", cell_deg, preregistered_cell_deg, is_preregistered }
    method                 { id, summary, parameters{...}, guards[], input_stats{...}, caveats[] }
    day                    day-level figures (ADS-B: background_evaluated, background_share,
                           day_confounded, cells_published, cells_suppressed_below_min_distinct;
                           AIS: on_land_detector, cells_published, cells_suppressed_...)
    data                   { dataset, name, licence, licence_url, attribution, coverage_notes[] }
  features[]               Polygon (one closed ring, [lon, lat], counter-clockwise)
    properties             cell_i, cell_j, status, degraded,
                           ADS-B: aircraft_observed, aircraft_sampled|null, aircraft_affected|null,
                                  affected_share|null
                           AIS:   vessels_observed, vessels_flagged{detector: n|null}, detectors[]

status values: ADS-B degraded, not_degraded, insufficient_sample, withheld_day_confounded, withheld_no_background; AIS anomalous, not_anomalous. degraded is true for degraded and anomalous only. The route-exposure report has its own kshana-route-exposure/v1 schema.

Studio map page specification#

For the Studio's map page, which loads a GeoJSON file the user provides (the page is built in the Studio source, not in this repository). Nothing is uploaded: the file is read in the browser, and the page makes no request to any data source.

Reading a file. Accept a file only if kshana_interference_map.schema starts with kshana-interference-map/ and format_version is a number the page supports; otherwise say the file is not a supported Kshana interference map. Read the fields listed above; ignore unknown ones. A file holds one source and one day. Several files may be loaded.

Cells. Draw each feature's polygon. Colour by status, and always say what each colour means in a legend. Four states, none of which may be hidden or merged:

State Meaning Where it comes from
Degraded (or anomalous for AIS) a high share of aircraft reported low accuracy, or vessels showed anomalies, that day degraded is true
Clear observed with enough aircraft or vessels and not flagged status is not_degraded or not_anomalous
Unassessed observed but not enough sampled aircraft, the day was confounded, or the day's background could not be estimated status is insufficient_sample, withheld_day_confounded or withheld_no_background
Not observed too few aircraft or vessels, so the cell was not published no feature for that cell

"Not observed" is the absence of a feature: draw it neutrally (no fill, or a hatch), never in the colour of "clear". Use a colour scheme that does not rely on red against green alone. Cell details on hover or selection show the counts in properties as given, and every null count as "withheld (fewer than 5)", never as zero. A viewer should also say when grid.is_preregistered is false that the file uses a cell size outside the pre-registered method.

Layers. ADS-B and AIS are separate layers with their own toggles, legends and licence lines. Never merge them into one colour field or one count, never draw them as one combined cell, and never sum their cells. Different days are different files: show one day at a time, with a day picker, rather than overlaying days.

Licence and attribution. For every visible layer show, on the map and in any export or screenshot, the data.attribution text verbatim, the data.licence with a link to data.licence_url, and the data.coverage_notes. If an ADS-B layer is shown, its ODbL licence applies to that layer only. Do not offer to export a combined file.

Method. Offer the method id and parameters (method.id, method.parameters) and the day figures in a details panel, including day_confounded (say plainly that no cell was called degraded because of the day's background) and input_stats.

Caveats to keep (show the file's own method.caveats and notice, and keep this wording or its equivalent visible with the map):

  • A degraded or anomalous cell is not a finding of interference. Other causes exist.
  • A cell with no colour was not observed. It is not evidence that the area was clear.
  • This is a description of past position reports, not a forecast and not a measurement of any receiver.
  • Coverage follows the data source; read the coverage notes.

The page must not name or show any individual aircraft or vessel, and the files contain none.

Route exposure. If the page also loads a kshana-route-exposure/v1 report, show its rows per day and source with the four shares (degraded, clear, unassessed, not observed) together, never the degraded share alone, plus the report's caveats, and each row's map_licence and map_attribution.

ADS-B method (kshana-interference-map/adsb/v2)#

Each airborne ADS-B report carries two self-assessed navigation quality fields: NIC (the containment radius of the position) and NACp (the 95% accuracy bound, EPU). When the receiver on board loses or distrusts its satellite fix, both drop, which is why they carry a signature of interference. They also drop for other reasons.

Pre-registered parameters (constants in the code, written into every file; a change means a new method version):

Parameter Value
Altitude floor barometric altitude of at least 5000 ft
Low-accuracy report NACp at most 6 (the reported EPU bound is 556 m, 0.3 NM, or larger, or unknown) or NIC at most 5 (the reported containment-radius bound is 1852 m, 1 NM, or larger, or unknown)
Good report NACp at least 8 and NIC at least 7; judged on the fields the input has (both when both are present, the one present otherwise)
Equipment baseline an aircraft counts in a cell only with at least 5 good reports outside that cell the same day
Aircraft sampled in a cell at least 3 reports in the cell
Aircraft affected in a cell at least 50% of its in-cell reports are low-accuracy
Minimum sample at least 10 sampled aircraft
Degraded at least 5 affected aircraft, an affected share of sampled aircraft of at least 0.30, and at least 0.15 above the day's median cell share
Confounded day median cell share (over at least 5 cells with a full sample) at least 0.15: no cell is declared degraded
No background fewer than 5 cells with a full sample, so the median cannot be estimated: a cell that meets the thresholds is withheld_no_background, not degraded
Publication minimum at least 5 distinct aircraft observed

These values were set from the definitions of the NIC and NACp codes and from the privacy rule before any data was looked at. They were not tuned on real data, and the tests use synthetic data only. Cell statuses: degraded, not_degraded, insufficient_sample (published but fewer than 10 sampled aircraft), withheld_day_confounded and withheld_no_background.

Version 2. Version 1 declared a cell degraded from as few as 3 affected aircraft, fewer than the 5-aircraft publication minimum, and skipped the day-background check without saying so when fewer than 5 cells were fully sampled. Version 2 requires at least 5 affected aircraft and withholds the call (withheld_no_background) when the background cannot be estimated. Both changes tighten the method (nothing that was not degraded in version 1 is degraded in version 2); the reason is that a published call must never rest on fewer aircraft than the number below which counts are withheld. Version 1 files were not released.

Why accuracy fields drop for reasons other than interference, and the guard for each:

Cause Guard
Older transponders that never report NIC or NACp, or report them at a fixed low value the equipment baseline: an aircraft with no good reports elsewhere that day is not counted
Low altitude and surface operations (terrain masking, different position formats) altitude floor; rows marked ground excluded
MLAT or other non-GNSS positions source_type filter when the column is present
One aircraft sending many reports distinct aircraft are counted, not reports
A constellation or augmentation outage, or a space-weather event, lowering accuracy over a wide area the cell must exceed the day's median cell share by a margin, and a day whose median is itself high is marked confounded and declares no cell
A fault in one aircraft's equipment needs a share of many distinct aircraft, with a minimum sample
Few aircraft in the cell minimum sample; below 5 observed the cell is not published

What remains: the method cannot tell interference from a regional cause that is local rather than wide (a local satellite-geometry effect or ground-based augmentation outage would look the same), and it is conservative in one direction: an aircraft that flies only inside an affected region all day has no baseline and is not counted, so persistent large areas are under-reported.

AIS method (kshana-interference-map/ais/v2)#

Per vessel per day, five detectors. A detector qualifies in a cell when it flags at least 5 distinct vessels making up at least 20% of the vessels observed there; the cell is anomalous if any detector qualifies.

Detector Rule (pre-registered)
on_land at least 3 reports in the cell more than 2000 m inland of the supplied coastline
circle a window of 20 consecutive reports (stride 10, gaps at most 30 min) whose mean radius from the centroid is 500 m to 20 km, radius coefficient of variation at most 0.15, one direction of travel with total winding of at least 270 degrees (steps at most 90 degrees, net at least 80% of total), and mean speed at least 2 kn
implausible_jump at least 2 jumps arriving in the cell: consecutive reports at least 1000 m apart, at most 1 h apart, implying more than 70 kn (the implied speed uses at least 1 s between the reports, so two reports with the same time stamp and positions 1 km or more apart count as a jump)
implausible_speed at least 3 reported speeds above 70 kn in the cell
same_position at least 5 distinct vessels reporting the same position (rounded to 1e-4 degrees, about 11 m) in the same 10-minute window

Cells with fewer than 5 distinct vessels are not published; every flagged count below 5, zero included, is reported as null.

Version 2. Version 1 qualified a detector from 3 flagged vessels and published counts of 3 and 4. Version 2 raises the flagged-vessel minimum to 5, the publication minimum, so an anomalous call never rests on fewer vessels than the number below which counts are withheld. This tightens the method and no version 1 file was released.

Known causes of false positives, which the share rule and the thresholds reduce but do not remove: vessels in rivers, canals and ports close to a coarse coastline (the 2 km inland buffer exists for this); two vessels sharing one identifier, which looks like jumps; transponders with a bad fix or test equipment; and loitering by pilot boats and search vessels, which can resemble circles. A coastline file at 1:10 million is accurate to about a kilometre.

The jump and speed detectors compare reports within one input file; a track split across files or days is not stitched.

Memory. Input is read line by line and never held whole. ADS-B keeps only per-aircraft per-cell counters. AIS keeps each accepted report in a compact record (about 40 bytes) until its day is evaluated, so memory grows with the number of AIS reports in the input; split a very large AIS input by day.

Route exposure#

kshana route-exposure --route route.geojson --map out/ [--map other/] \
    [--from 2026-03-01] [--to 2026-03-07] [--out report.json] [--json]

--route is a GeoJSON LineString (bare, in a Feature, or in a FeatureCollection) or a CSV of lat,lon lines. --map takes map files or directories of them, and may be repeated. Waypoints are joined by straight lines in latitude and longitude, so densify long legs; a leg takes the short way round in longitude, so one that crosses the antimeridian (179.5 E to 179.5 W) crosses it. The route is split into pieces of about 250 m. Each piece is measured between its own two end points on a sphere of radius 6371.0088 km (the same path that decides which cell it lies in) and assigned to the cell holding its midpoint.

Per day and per source the report gives the route length and four shares that sum to 1: share_degraded, share_not_degraded, share_unassessed (cell present but without a call) and share_not_observed (cell absent from the map). Read the last two with the first: a route with a low degraded share and a high not-observed share has not been shown to be clear. ADS-B and AIS rows are reported separately and never combined. Each row carries the map's licence and attribution.

Library functions#

For callers that hold data in memory (bindings, the MCP server, the browser build), the module kshana::interference_map::api exposes the same methods with text or bytes in and JSON out, touching no file and no network:

Function In Out
adsb_maps_from_csv(csv, &DatasetSpec, cell_deg) ADS-B CSV text Vec<DayMap>
adsb_maps_from_readsb_traces(&[&[u8]], &DatasetSpec, cell_deg) one byte slice per readsb trace file (gzip or plain) Vec<DayMap>
ais_maps_from_csv(csv, &DatasetSpec, cell_deg, Option<&str>) AIS CSV text, optional land GeoJSON text Vec<DayMap>
route_exposure(route_text, &[&str], from, to) route text, map GeoJSON texts, optional YYYY-MM-DD bounds kshana-route-exposure/v1 JSON

A DayMap has file_name (<source>-<dataset>-<date>.geojson), date and geojson (the v1 document). DatasetSpec is Preset("adsb-lol" | "noaa-marinecadastre" | "kystverket") or Custom { licence, licence_url, attribution }; DEFAULT_CELL_DEG is 0.5. Errors are MapError. The dataset rules (approved presets, licence text required, kind must match) apply exactly as on the command line.

External validation#

tests/interference_map_reference.rs compares Kshana with independent implementations on synthetic inputs, with tolerances pre-registered in tests/fixtures/interference_map_ref/PREREGISTRATION.md: NIC and NACp code meanings against pyModeS decoding of synthetic ADS-B frames, AIS not-available values against pyais decoding of synthetic AIVDM sentences, grid cell assignment against shapely, route length per cell state against shapely and GeographicLib, and inland masking against shapely and pyproj. The fixtures are made by scripts/gen_interference_map_ref.py; CI needs no Python. This validates how the codes and field values are read and the geometry. It does not validate that a flagged cell is interference.

Tests#

All tests use synthetic generated data and no network: unit tests in src/interference_map/, and tests/interference_map_cli.rs for the commands end to end, including that no identifier reaches an output and that refusals leave no files behind.