kimo
GuideAdvanced12 minDefense

Ingest ADS-B feeds into Kimo

To ingest ADS-B feeds into Kimo, connect a source (the OpenSky Network API, your own receivers through Kafka, or a licensed commercial feed), land raw state vectors unchanged, normalize them into one typed state_vectors model with units and quality fields, deduplicate across receivers, then derive flights and coverage_cells models on top. This guide walks through each step with the schema, SQL and YAML we use, and the licensing and rate-limit checks to do before you poll anything.

Hugo Lefèvre · Aviation data analyst
12 min read

At a glance

Level
Advanced
Time
12 min

Prerequisites

  • A Kimo Defense Intelligence workspace (the simulated demo works for every step except live polling)
  • Access to an ADS-B source: OpenSky API client credentials, your own receivers, or a licensed feed
  • A written license or agreement for the source if your use is commercial or operational
  • Optional: Kimo Bridge installed next to your receiver database if data must stay on your servers

You will end up with

A deduplicated, typed state_vectors model with quality fields, a versioned flights model and an hourly coverage_cells model, ready for the Airspace map, alert rules and Ask Kimo.

This is the hands-on companion to the whitepaper Airspace Awareness from Open Data. It assumes you know what ADS-B and Mode S are and want a pipeline you can trust. Everything here is civil, cooperative surveillance data; sample rows are simulated.

§01Which ADS-B source should you connect?

There are three realistic sources, and many teams end up with two of them. The right choice depends on the region you care about, whether you need navigation-quality fields, and what your license allows.

SourceWhat you getQuality fields (NIC/NACp)Watch out for
OpenSky REST API (/states/all)Decoded state vectors for a bounding box, 5 s resolution when authenticatedNot in the state vectorCredit quotas; license terms for commercial or operational use
Own receivers (via Kafka or a database)Raw or decoded messages from antennas you controlYes, if you decode operational status and position messagesCoverage limited to your antennas; you operate the hardware
Licensed commercial or historical feedWider coverage, history, sometimes MLATDepends on the providerContract scope, redistribution rights
ADS-B source options. OpenSky details from the OpenSky REST API documentation.

The OpenSky state vector exposes 18 fields per aircraft, including icao24, callsign, positions, barometric and geometric altitude, velocity, track, vertical rate, squawk, the special-purpose indicator, position_source (0 ADS-B, 1 ASTERIX, 2 MLAT, 3 FLARM) and, with extended=1, an aircraft category1. It does not carry NIC or NACp, so if you plan to build a GNSS interference layer you need either your own decoded messages or a historical source with raw operational-status messages. Published research did exactly that, merging OpenSky state vectors with NACp decoded from operational-status messages on icao24 and timestamp6.

§02How many API credits will polling cost?

OpenSky meters /states/all by bounding-box area: a box of at most 25 square degrees costs 1 credit per request, rising to 4 credits for boxes over 400 square degrees or global queries. Daily quotas are 400 credits anonymous, 4,000 for a standard user and 8,000 for an active feeder; licensed users get 14,400 credits per hour1. Anonymous requests return only the latest vectors at 10-second resolution; authenticated requests have 5-second resolution and can look back up to one hour1.

Polling intervalRequests per dayCredits/day (≤25 sq° box)Fits standard quota (4,000)?
5 s17,28017,280No
10 s8,6408,640No (fits neither standard nor feeder)
30 s2,8802,880Yes
60 s1,4401,440Yes, with room for a second box
Worked example: credit budget for polling one small bounding box, computed from the published OpenSky credit table.

The practical conclusion: research-tier polling of a region is a 30–60 second picture, which is fine for coverage maps, daily interference layers and retrospective analysis, and not fine for real-time alerting. If you need seconds-level latency, ingest from your own receivers (OpenSky's /states/own endpoint for your own sensors costs no credits1) or from a licensed feed.

§03Step 1: connect the source in Kimo

  1. Step 1:

    Create the connector

    In Connectors, choose OpenSky Network or ADS-B Air Traffic. For own receivers streaming to a topic, choose Kafka.

  2. Step 2:

    Set credentials

    OpenSky uses the OAuth2 client-credentials flow only; create an API client on your OpenSky account page and paste the client ID and secret. Tokens expire after 30 minutes and Kimo refreshes them automatically1.

  3. Step 3:

    Declare the area and cadence

    Enter one or more bounding boxes (lamin, lomin, lamax, lomax) and a polling interval that fits your credit budget.

  4. Step 4:

    Declare license basis and retention

    Record the license tier and set raw retention (we suggest 30 days for raw rows; aggregates live longer).

  5. Step 5:

    Choose where data lives

    Pick Cloud mode to sync into Kimo, or Bridge mode so rows stay in your own database and Kimo queries through Kimo Bridge.

Before automating, test the call by hand. This is the documented token exchange and a bounding-box query1:

Test an OpenSky request (bounding box over Switzerland, from the API docs)
bash
export TOKEN=$(curl -s -X POST \
  "https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials" \
  -d "client_id=$CLIENT_ID" -d "client_secret=$CLIENT_SECRET" | jq -r .access_token)

curl -s -H "Authorization: Bearer $TOKEN" \
  "https://opensky-network.org/api/states/all?lamin=45.8389&lomin=5.9962&lamax=47.8229&lomax=10.5226&extended=1" \
  | jq '.time, (.states | length)'

# Check your remaining credits in the response headers
curl -s -D - -o /dev/null -H "Authorization: Bearer $TOKEN" \
  "https://opensky-network.org/api/states/all?lamin=45.8389&lomin=5.9962&lamax=47.8229&lomax=10.5226" \
  | grep -i x-rate-limit

For your own receivers, the bridge is the cleanest option when the decoder writes to a local Postgres or ClickHouse: install it with the Docker guide, add the database as a source, and nothing leaves your network except query results.

§04Step 2: land raw rows unchanged

Keep a raw table that stores exactly what the source returned, plus ingestion metadata: the response time, the request bounding box, the connector ID and ingested_at. Never fix data in the raw layer. When a decoding question comes up three months later ("why did this aircraft jump 40 km?"), the raw row is your evidence. OpenSky returns each state as a positional array, so the raw table can simply hold the JSON array; for own receivers, keep the raw hex frame alongside the decoded fields, as the OpenSky network itself does with its stored metadata3.

Should raw aircraft data live in Kimo’s cloud or stay on your servers?

Raw state vectors are bulky, and for some teams they are sensitive because of what they reveal about local traffic. You can choose per source. In Cloud mode, Kimo syncs the raw and normalized tables into its managed storage, which is the fastest option for history and ad hoc exploration. In Bridge mode, the tables stay in your own database; Kimo Bridge opens an outbound-only, mutually authenticated tunnel and Kimo pushes queries down through it, storing nothing on its side unless you enable short-lived result caches. A common hybrid keeps raw vectors on your servers through the bridge and lets only hourly aggregates (coverage and quality cells) sync to the cloud for fast maps. The models in this guide are identical in both modes.

§05Step 3: normalize into a state-vector schema

Every downstream model reads from one typed table. The schema below is what the Airspace watch template ships with. Units are SI to match OpenSky (meters, m/s); convert to feet and knots only in the presentation layer.

ColumnTypeMeaning and rule
obs_timetimestamp (UTC)Time of the position (time_position); vectors without one are not loaded
icao24text (6 hex, lower case)24-bit transponder address; track key, not an identity
callsigntext, nullableTrimmed; may change in flight or be blank
lat, londoubleWGS-84 degrees; null rows are kept but excluded from maps
baro_alt_mdoubleBarometric altitude, meters
geo_alt_mdoubleGeometric altitude, meters; never mixed with barometric
gs_ms, track_deg, vrate_msdoubleGround speed, true track, vertical rate
on_groundbooleanFrom surface position reports
squawktext(4)Mode A code as text to keep leading zeros
position_sourcesmallint0 ADS-B, 1 ASTERIX, 2 MLAT, 3 FLARM
dfsmallint, nullable17 or 18 when decoding raw frames; 18 marks non-transponder or TIS-B
nic, nacpsmallint, nullableNavigation integrity and accuracy categories, when available
nacp_age_sint, nullableSeconds since the NACp value was received
receiver_countsmallintDistinct receivers that heard this state
feedtextConnector that produced the row
Normalized state_vectors schema. Field definitions follow the OpenSky API documentation and Sun, The 1090 Megahertz Riddle.

Two columns deserve explanation. df matters because Downlink Format 18 is used by non-transponder ADS-B equipment and TIS-B ground rebroadcasts4; a TIS-B target is a ground system relaying radar-derived data and can duplicate a target you already see directly. nacp_age_s matters because NACp arrives in the less frequent operational-status message, so the value attached to a position is the last one received, and you should know how old it is. NIC and NACp are defined in US rules as the integrity containment radius and position accuracy of the reported position5; keep them even if you do not use them yet.

Normalize OpenSky raw arrays into state_vectors (DuckDB / Postgres-style SQL)
sql
insert into state_vectors
select
  to_timestamp(s[4]::bigint)                                as obs_time,
  lower(s[1]::text)                                         as icao24,
  nullif(trim(s[2]::text), '')                              as callsign,
  s[7]::double                                              as lat,
  s[6]::double                                              as lon,
  s[8]::double                                              as baro_alt_m,
  s[14]::double                                             as geo_alt_m,
  s[10]::double                                             as gs_ms,
  s[11]::double                                             as track_deg,
  s[12]::double                                             as vrate_ms,
  s[9]::boolean                                             as on_ground,
  s[15]::text                                               as squawk,
  s[17]::smallint                                           as position_source,
  null::smallint                                            as df,
  null::smallint                                            as nic,
  null::smallint                                            as nacp,
  null::int                                                 as nacp_age_s,
  1                                                         as receiver_count,
  r.connector_id                                            as feed
from raw_opensky_states r, unnest(r.states) as t(s)
where s[4] is not null;   -- skip vectors without a position timestamp

§06Step 4: deduplicate and check plausibility

Overlapping bounding boxes, multiple feeds and multiple receivers all produce duplicates. Deduplicate on address, observation time and rounded position, keeping the row heard by the most receivers. Then run a cheap kinematic check: if the distance between consecutive fixes implies a speed no civil aircraft can fly, flag the row. Most failures are decoding artifacts or position-encoding glitches, but some are worth an analyst's eye, and ADS-B has no authentication to rule anything out.

Deduplicate and flag implausible jumps
sql
create or replace view state_vectors_clean as
with ranked as (
  select *,
    row_number() over (
      partition by icao24, obs_time, round(lat, 4), round(lon, 4)
      order by receiver_count desc, feed
    ) as rn
  from state_vectors
  where lat is not null and lon is not null
),
dedup as (select * from ranked where rn = 1),
stepped as (
  select *,
    lag(obs_time) over w as prev_time,
    lag(lat) over w      as prev_lat,
    lag(lon) over w      as prev_lon
  from dedup
  window w as (partition by icao24 order by obs_time)
)
select *,
  -- haversine distance in meters
  2 * 6371000 * asin(sqrt(
      pow(sin(radians(lat - prev_lat) / 2), 2) +
      cos(radians(prev_lat)) * cos(radians(lat)) *
      pow(sin(radians(lon - prev_lon) / 2), 2)
  )) / nullif(extract(epoch from obs_time - prev_time), 0) as implied_ms,
  coalesce(
    2 * 6371000 * asin(sqrt(
      pow(sin(radians(lat - prev_lat) / 2), 2) +
      cos(radians(prev_lat)) * cos(radians(lat)) *
      pow(sin(radians(lon - prev_lon) / 2), 2)
    )) / nullif(extract(epoch from obs_time - prev_time), 0) > 350,
    false
  ) as implausible_jump   -- 350 m/s is well above civil cruise speeds
from stepped;

§07Step 5: segment flights with a versioned rule

A "flight" is a modeling decision. The OpenSky founders split messages into flights when an aircraft was silent for ten minutes, noting that a longer threshold wrongly merges quick turnarounds and a shorter one wrongly splits flights that leave and re-enter coverage3. Use the same idea, but make the threshold a parameter, record the rule version on each flight, and add a ground-state boundary so a landing always ends a flight.

flights model: gap- and ground-based segmentation (rule v2, 10-minute gap)
sql
create or replace table flights as
with marked as (
  select *,
    case
      when prev_time is null then 1
      when obs_time - prev_time > interval '10 minutes' then 1
      when lag(on_ground) over w = true and on_ground = false then 1
      else 0
    end as new_flight
  from state_vectors_clean
  window w as (partition by icao24 order by obs_time)
),
numbered as (
  select *, sum(new_flight) over (partition by icao24 order by obs_time) as seg
  from marked
)
select
  icao24 || '-' || strftime(min(obs_time), '%Y%m%d%H%M') as flight_id,
  icao24,
  any_value(callsign)          as callsign,
  min(obs_time)                as first_seen,
  max(obs_time)                as last_seen,
  count(*)                     as positions,
  max(baro_alt_m)              as max_baro_alt_m,
  sum(case when implausible_jump then 1 else 0 end) as implausible_jumps,
  'v2-gap10m-ground'           as segmentation_rule
from numbered
group by icao24, seg;

§08Step 6: build a coverage model

Coverage is the denominator for everything else. Without it, an empty patch of map is ambiguous and trends are meaningless. Compute it from your own clean positions: per hexagonal cell, altitude band and hour, count distinct aircraft and positions. Cells with no recent observations are "unknown", not "empty".

coverage_cells: hourly coverage by H3 cell and altitude band (DuckDB with the h3 extension)
sql
create or replace table coverage_cells as
select
  h3_latlng_to_cell(lat, lon, 5)                   as h3_cell,
  date_trunc('hour', obs_time)                     as hour,
  case
    when baro_alt_m < 3000  then 'low'
    when baro_alt_m < 7500  then 'mid'
    else 'high'
  end                                              as alt_band,
  count(distinct icao24)                           as aircraft,
  count(*)                                         as positions
from state_vectors_clean
where not on_ground
group by all;

Resolution 5 cells average roughly 250 km², which works for regional pictures. Use a coarser resolution if your network is sparse; the interference guide explains how to choose based on aircraft per cell per day.

§09Step 7: declare the models in Kimo

Wrap the tables in Kimo models so measures are defined once and reused by the Airspace map, alert rules and Ask Kimo. The YAML lives with your other models and is versioned.

models/airspace.yaml
yaml
models:
  - name: state_vectors
    source: state_vectors_clean
    primary_key: [icao24, obs_time]
    time_dimension: obs_time
    dimensions:
      icao24: { type: string, description: "24-bit address; not an identity" }
      callsign: { type: string }
      squawk: { type: string }
      position_source:
        type: enum
        values: { 0: ADS-B, 1: ASTERIX, 2: MLAT, 3: FLARM }
      alt_band: { sql: "case when baro_alt_m < 3000 then 'low' when baro_alt_m < 7500 then 'mid' else 'high' end" }
    measures:
      aircraft: { sql: count(distinct icao24) }
      positions: { sql: count(*) }
      implausible_share: { sql: avg(case when implausible_jump then 1.0 else 0 end), format: percent }
    retention: 30d

  - name: flights
    source: flights
    primary_key: [flight_id]
    time_dimension: first_seen
    measures:
      flights: { sql: count(*) }
      median_duration_min: { sql: "median(extract(epoch from last_seen - first_seen) / 60)" }

  - name: coverage_cells
    source: coverage_cells
    time_dimension: hour
    measures:
      covered_cells: { sql: count(distinct h3_cell) }
      aircraft_per_cell: { sql: avg(aircraft) }

§10Step 8: validate before you trust it

Ingestion acceptance checks

  • Row counts per poll are stable; sudden drops are investigated as coverage or credit exhaustion (HTTP 429) before anything else.
  • Fewer than 1% of clean rows are flagged implausible_jump in a normal week; spikes are reviewed.
  • No duplicate (icao24, obs_time) pairs remain in state_vectors_clean.
  • Barometric and geometric altitudes are never plotted on the same axis.
  • The share of rows with position_source = 2 (MLAT) is known, so analysts know how much is independently located.
  • Coverage cells are rendered on the map, and "unknown" is visually distinct from "no traffic".
  • The connector shows a license basis and a raw retention period.

§11Troubleshooting common ingestion problems

SymptomLikely causeFix
HTTP 401 mid-runToken expired (30-minute lifetime)Refresh the token and retry; Kimo does this automatically
HTTP 429 in the afternoonDaily credit bucket exhaustedLengthen the interval, shrink the box, or move to own receivers
HTTP 400 on historical requestsAsking for more than one hour in the pastUse a historical source for backfills
Aircraft appear twice, slightly offsetDirect ADS-B plus TIS-B rebroadcast, or overlapping feedsKeep df and feed; deduplicate by address and time
Flights split mid-routeCoverage gap longer than the segmentation thresholdRaise the threshold for sparse regions and bump the rule version
Map shows empty sky over the seaNo receivers in rangeRender coverage; do not infer absence
Status codes and limits from the OpenSky REST API documentation.

Once ingestion is stable, continue with GNSS interference detection and alerting rules, or start from the Airspace watch template, which contains these models pre-wired to simulated data.

Sources

6 references
  1. OpenSky REST API documentation (opens in a new tab)
    OpenSky Networkopenskynetwork.github.io

    State vector fields, bounding-box parameters, OAuth2 client credentials, time resolution, credit quotas and costs.

  2. General Terms of Use & Data License Agreement (opens in a new tab)
    OpenSky Networkopensky-network.org

    License required for commercial entities and operational REST API use.

  3. Bringing Up OpenSky: A Large-scale ADS-B Sensor Network for Research (opens in a new tab)
    Schäfer et al. — ACM/IEEE IPSN2014cs.ox.ac.uk

    Stored message metadata; ten-minute flight segmentation tradeoff.

  4. The 1090 Megahertz Riddle (2nd ed.) — ADS-B basics (opens in a new tab)
    Junzi Sun, TU Delft (mode-s.org)mode-s.org

    DF17 vs DF18 (non-transponder and TIS-B); frame structure.

  5. 14 CFR § 91.227 — ADS-B Out equipment performance requirements (opens in a new tab)
    Cornell Law School Legal Information Institutelaw.cornell.edu

    Definitions of NACp and NIC.

  6. GNSS Jamming and Its Effect on Air Traffic in Eastern Europe (opens in a new tab)
    Figuet, Waltert, Felux, Olive — Engineering Proceedings 28(1), 122022digitalcollection.zhaw.ch

    Merged OpenSky state vectors with NACp decoded from operational-status messages on icao24 and timestamp.

External sources were accessed at the time of writing. Kimo product details, customers and figures in examples are illustrative unless a source is cited.

0%

Mark as done

0 of 11 sections done

Frequently asked questions

Does the OpenSky API include NIC and NACp?

Not in the live state vector. Use your own receivers with a decoder that outputs operational-status and position quality fields, or a historical source with raw messages, if you need them.

How often should I poll OpenSky?

For one box of up to 25 square degrees, every 30 to 60 seconds fits a standard daily quota. Polling every 10 seconds needs 8,640 credits a day, more than the standard or active-feeder allowance.

Can I use OpenSky data in a commercial or operational setting?

Only with a written license or agreement from OpenSky. Its default terms cover non-profit research and education.

Why keep barometric and geometric altitude separate?

They measure different things: pressure altitude versus height above the reference ellipsoid. Mixing them creates false climbs and descents and breaks altitude-based alert rules.

Can the data stay on our own servers?

Yes. Run the receiver database locally and connect it through Kimo Bridge in Bridge mode; Kimo queries it through an outbound-only tunnel and stores nothing by default.

Put it to work

Skip the setup — start from a working version.

Airspace watch: Live air picture with emergency squawks, loitering and geofence alerts.

All resources
Whitepaper
Defense

Airspace Awareness from Open Data

ADS-B, Mode-S and GNSS interference: what open aviation data can reveal, its limits, and how to fuse it responsibly.

Hugo Lefèvre
32 pages
GuideIntermediate
Defense

Airspace alerting rules that analysts trust

Emergency squawks, loitering, unusual altitude profiles and geofences, tuned to avoid alert fatigue.

Jonas Becker
11 min read

Airspace awareness from open data.

Fuse ADS-B, AIS and OSINT feeds on your own infrastructure. The demo runs entirely on simulated data.