Skip to main content

pyasteryx

CI Python License: MIT

A fast, dependency-free, specification-driven decoder for EUROCONTROL ASTERIX surveillance data — built for Air Traffic Management research, surveillance analytics and big-data (PySpark/Arrow) processing, with first-class CAT062 system-track support.

pip install pyasteryx
from pyasteryx import Decoder, tracks

for track in tracks(Decoder().iter_multicast("239.1.1.1", 8600)):
    print(track.callsign, track.flight_level, track.ground_speed_kt)
DLH4AB 350.0 388.8
BAW22  110.0 254.1

Why another ASTERIX library?

Existing Python ASTERIX decoders stop at the wire format: they hand back nested dictionaries of raw integers keyed by item id, and leave the rest to you. pyasteryx goes the rest of the way.

pyasteryx typical alternatives
Live UDP / multicast feeds built in not supported
PCAP capture files built in, streaming manual packet extraction
Absolute UTC timestamps built in, midnight-wrap aware raw 1/128 s counter
Named fields in engineering units Track view msg["I062/380"]["ID"]["ACID"]
Coded fields as text built in ("triangulation") raw integer (3)
Command-line tool pyasteryx command none
DataFrame / Parquet / GeoJSON export built in none
Multiple editions of a category bundled, pinnable one hardcoded edition
Error recovery on a dirty feed per-block skip + counter all-or-nothing
Runtime dependencies none varies
Install footprint 70 KB wheel, 222 KB installed varies

Editions bundled today:

Category Description Editions
CAT062 System Track Data 1.17, 1.18
CAT021 ADS-B Target Reports 2.4, 2.6
CAT048 Monoradar Target Reports 1.21

(Bold = default edition when none is pinned.)

From the wire to a track

Here is one real CAT062 system-track record — 48 octets, exactly as it arrives in a UDP datagram:

3E 00 30 9B 5F A4 19 C8 58 78 00 00 80 00 00 00
20 00 00 03 20 00 00 0F 11 C0 3C 6D A9 10 C2 34
04 28 20 04 D2 0D 30 B0 04 08 0C 04 05 78 01 00

Nothing in those bytes says where one field ends and the next begins. The layout is carried by a bitmap (the FSPEC), and the meaning by the edition of the standard you happen to be decoding against:

3E                       CAT = 62
00 30                    LEN = 48 octets, header included
9B 5F A4                 FSPEC — 3 octets of presence bits, FX-chained
   19 C8                 I062/010  data source identifier
   58 78 00              I062/070  time of track
   00 80 00 00 00 20 00 00   I062/105  position, WGS-84
   03 20 00 00           I062/185  cartesian velocity
   0F 11                 I062/060  Mode 3/A
   C0 3C 6D A9 10 C2 34 04 28 20   I062/380  aircraft-derived data
   04 D2                 I062/040  track number
   0D 30                 I062/080  track status (extended, 2 extents)
   B0 04 08 0C           I062/290  system track update ages (compound)
   04                    I062/200  mode of movement
   05 78                 I062/136  measured flight level
   01 00                 I062/220  rate of climb/descent

Decoded the usual way, you get the structure back, faithfully — and then you are on your own:

{
  "I062/010": {"SAC": 25, "SIC": 200},
  "I062/070": {"ToT": 45296.0},
  "I062/105": {"Lat": 45.0, "Lon": 11.25},
  "I062/185": {"Vx": 200.0, "Vy": 0.0},
  "I062/060": {"V": 0, "G": 0, "CH": 0, "Mode3A": "7421"},
  "I062/380": {"ADR": {"ADR": "3C6DA9"}, "ID": {"ACID": "DLH4AB"}},
  "I062/040": {"TrkN": 1234},
  "I062/080": {"MON": 0, "SPI": 0, "MRH": 0, "SRC": 3, "CNF": 0,
               "SIM": 0, "TSE": 0, "TSB": 1, "FPC": 1, "AFF": 0,
               "STP": 0, "KOS": 0},
  "I062/290": {"TRK": {"TRK": 1.0}, "SSR": {"SSR": 2.0}, "MDS": {"MDS": 3.0}},
  "I062/200": {"TRANSA": 0, "LONGA": 0, "VERTA": 1, "ADF": 0},
  "I062/136": {"MFL": 35000.0},
  "I062/220": {"RoC": 1600.0},
}

To get a ground speed out of that you must know that Vx/Vy are metres per second in a cartesian frame and convert; to get a heading, that Vx is the easterly component; to get a timestamp, that ToT is seconds since midnight UTC and that the date is not in the message at all. SRC: 3 and VERTA: 1 mean nothing without the specification open beside you.

With pyasteryx, the same 48 octets:

from datetime import date
from pyasteryx import Decoder, tracks

t = next(tracks(Decoder().decode(raw), day=date(2026, 9, 5)))
t.timestamp               datetime(2026, 9, 5, 12, 34, 56, tzinfo=utc)
t.track_number            1234
t.callsign                'DLH4AB'
t.address                 '3C6DA9'
t.mode_3a                 '7421'
t.position                (45.0, 11.25)
t.flight_level            350.0
t.measured_altitude_ft    35000.0
t.ground_speed_kt         388.76889848812095
t.track_angle_deg         90.0
t.vertical_rate_fpm       1600.0
t.vertical_mode           'climb'
t.transversal_mode        'constant course'
t.altitude_source         'triangulation'
t.is_confirmed            True
t.is_first_report         True     # this track was just created
t.is_last_report          False
t.flight_plan_correlated  True
t.contributing_sensors    ('SSR', 'MDS')

Same bytes, same fidelity — the raw items are still on t.message — but the unit conversions, the missing date, and the code tables are done.

The Track view

This is the part that saves the most time. A CAT062 record decodes to a nested structure faithful to the standard and awkward to work with:

msg["I062/380"]["ID"]["ACID"]     # 'DLH4AB'
msg["I062/185"]["Vx"]             # 123.75  — m/s, cartesian east
msg["I062/080"]["SRC"]            # 3       — meaning?

Wrapping it in a Track gives you what you actually wanted:

from pyasteryx import Decoder, tracks
from datetime import date

for t in tracks(Decoder().iter_pcap("feed.pcap"), day=date(2026, 9, 5)):
    t.callsign            # 'DLH4AB'
    t.track_number        # 1234
    t.address             # '3C6DA9'   (24-bit ICAO)
    t.mode_3a             # '7421'     (octal squawk)
    t.timestamp           # datetime(2026, 9, 5, 12, 34, 56, tzinfo=utc)
    t.position            # (45.0, 11.25)
    t.flight_level        # 350.0
    t.ground_speed_kt     # 388.8
    t.track_angle_deg     # 90.0
    t.vertical_rate_fpm   # 1600.0
    t.vertical_mode       # 'climb'
    t.altitude_source     # 'triangulation'
    t.emergency           # None, or 'unlawful interference'
    t.is_first_report     # True  — this track was just created
    t.is_last_report      # False — track number about to be released
    t.contributing_sensors  # ('SSR', 'MDS')

Every property returns None when the record does not carry that item, which is the normal case — a CAT062 feed sends mostly partial updates. Values are resolved from whichever item carries them: ground_speed_kt prefers the aircraft-derived I062/380 GSP and falls back to deriving it from the tracker's I062/185 cartesian velocity.

Track holds a reference to its Message and computes on access, so wrapping a whole feed costs one small object per record. The raw message is always there on track.message if you need an item the view does not surface.

Building trajectories

from pyasteryx import Decoder, tracks, group_by_track

trajectories = group_by_track(tracks(Decoder().iter_pcap("feed.pcap")))
for (sac, sic, number), points in trajectories.items():
    print(number, len(points), points[0].callsign)

Track numbers are recycled and are only unique within one emitting system, so the key includes the source by default.

Reading data

Every source has an eager decode_* and a lazy iter_* twin. The lazy form streams in constant memory, so captures larger than RAM are fine.

from pyasteryx import Decoder

decoder = Decoder()

decoder.decode(raw_bytes)                    # bytes / bytearray / memoryview
decoder.decode_file("capture.bin")           # raw ASTERIX file
decoder.decode_pcap("capture.pcap")          # Ethernet/IPv4/UDP -> ASTERIX
decoder.decode_stream(open("f.bin", "rb"))   # any binary file-like

for msg in decoder.iter_pcap("huge.pcap"):   # constant memory
    ...

Live feeds

CAT062 is normally distributed as UDP multicast. Point the decoder straight at it — no socket setup, no packet reassembly:

decoder = Decoder(on_error="skip")   # one bad datagram shouldn't end the session

for msg in decoder.iter_multicast("239.1.1.1", 8600, iface="10.0.0.5"):
    ...

for msg in decoder.iter_udp(8600):   # unicast
    ...

Set iface on a multi-homed host, otherwise the OS joins on the default route, which is rarely the operational network. The reader raises SO_RCVBUF well above the default, because a busy feed drops datagrams in the kernel silently.

Timestamps

ASTERIX carries seconds-since-midnight-UTC and no date. Supply the day:

from datetime import date
tracks(messages, day=date(2026, 9, 5))

For a feed running across midnight, let the resolver follow the wrap — otherwise a trajectory jumps 24 hours backwards at 00:00:

tracks(messages, resolve_day=True)

Or drive it yourself with DayResolver:

from pyasteryx import DayResolver

resolver = DayResolver()
for msg in decoder.iter_multicast("239.1.1.1", 8600):
    ts = resolver.resolve(msg["I062/070"]["ToT"])

Command line

pyasteryx info                                  # bundled categories and editions
pyasteryx stats capture.pcap                    # what's in this capture?
pyasteryx decode capture.pcap --cat 62 -n 10    # raw item-keyed records, as JSONL
pyasteryx tracks capture.pcap -f csv -o out.csv # flat named track rows
pyasteryx listen 239.1.1.1:8600 --tracks        # live feed to stdout
$ pyasteryx stats capture.pcap
source            capture.pcap
records           48213

by category:
  CAT062          48213

by source (SAC/SIC):
  CAT062  25/200    48213

distinct tracks   1197
time of day span  12:00:00.031 .. 12:59:59.968  (3599.9 s)

Everything streams, so pyasteryx decode big.pcap | head stops the decode rather than buffering the capture. - reads raw ASTERIX from stdin.

Exporting

Pass either raw messages or tracks — tracks give you named, engineering-unit columns, which is usually what you want for analysis.

from pyasteryx import Decoder, tracks
from pyasteryx.exporters import to_pandas, to_arrow, to_polars, to_parquet, to_geojson

points = tracks(Decoder().iter_pcap("feed.pcap"), day=date(2026, 9, 5))

df = to_pandas(points)
df.groupby("callsign")["flight_level"].max()

to_parquet(points, "tracks.parquet")
fc = to_geojson(points)          # FeatureCollection; position columns auto-detected

Install the optional dependencies with pip install "pyasteryx[export]".

PySpark

The decoder is pure Python and holds only immutable state, so it is safe to broadcast and reuse across a partition.

from pyasteryx.spark import asterix_decode_udf, decode_partition
from pyspark.sql.functions import col

# Vectorized (Arrow) pandas_udf: binary column -> JSON array of records
decode = asterix_decode_udf(editions={62: "1.18"})
df = df.withColumn("records", decode(col("payload")))

# Or RDD-style, one decoder per partition:
rows = df.rdd.mapPartitions(lambda it: decode_partition(it, "payload"))

Install with pip install "pyasteryx[spark]".

Editions

The ASTERIX wire format does not carry its edition, so you pin it per category. Without a pin, the newest bundled edition is used.

decoder = Decoder(editions={62: "1.18", 21: "2.4"})
pyasteryx decode capture.pcap --edition 62=1.17

Error handling

By default a malformed block raises a typed exception. On a live feed or a field recording you usually want to keep going:

decoder = Decoder(on_error="skip")
messages = decoder.decode_pcap("dirty.pcap")
print(f"{decoder.errors} blocks skipped")

A block whose header framed correctly is skipped precisely — its declared length still says where the next block starts, so only that block is lost. Only an unreadable header forces the rest of the buffer to be abandoned.

All exceptions derive from AsteryxError:

AsteryxError
├── DecodeError
│   ├── TruncatedMessageError      buffer ended mid-message
│   ├── InvalidLengthError         impossible declared length
│   ├── InvalidFspecError          FSPEC malformed or never terminates
│   ├── UnsupportedCategoryError   no specification loaded for this category
│   └── UnsupportedItemError       FSPEC selects an item the spec doesn't define
├── SpecificationError             a specification file is malformed
├── PcapError                      unreadable pcap file
└── NetworkError                   feed socket could not be opened or joined

Footprint

pyasteryx has no runtime dependencies and stays small enough to sit in a container image or a Spark job without thought:

Wheel (download) 70 KB
Installed on disk 222 KB
import pyasteryx ~18 ms
Decoder() 0.5 ms, 10 KiB
First CAT062 record decoded 2.2 ms, 105 KiB

Specifications load lazily. Decoder() only indexes the bundled files — their paths already encode the category and edition — and parses one when that category is first decoded. A decoder on a CAT062 feed never pays for CAT021 or CAT048, or for the older editions of CAT062. If you want everything parsed up front (to validate every bundled file, say):

from pyasteryx import SpecRegistry
from pyasteryx.spec import load_bundled_categories

registry = SpecRegistry(load_bundled_categories())

The bundled specification files are shipped minified, which is where over half their on-disk size went. They are still plain JSON — inspect one with python -m json.tool, or regenerate it readable with tools/xml_to_spec.py --pretty.

Architecture

Adding a category or edition is a data change, not a code change:

pyasteryx/
├── decoder.py     Public Decoder API (decode_* / iter_*)
├── track.py       CAT062 Track view: named, engineering units
├── timing.py      Time-of-day -> absolute UTC, midnight-wrap aware
├── enums.py       Coded-field meanings
├── cli.py         The `pyasteryx` command
├── io/            Byte sources: binary.py, pcap.py, net.py (streaming-first)
├── parser/        Category-agnostic structure: header, fspec, items, record
├── spec/          Data-driven definitions + edition-aware registry
│   └── data/catNNN/<edition>.json
├── exporters/     pandas / arrow / polars / parquet / geojson
└── spark.py       PySpark helpers (optional)

The parser layer contains no field meaning, and the public API is deliberately frozen, so the parser can later be swapped for a Rust/PyO3 implementation without users noticing.

Supported data-item encodings: fixed · extended (variable, multi-octet extents) · repetitive · compound · explicit (RE/SP) · signed/unsigned · scaled · hex · octal · ASCII · 6-bit (IA5) characters.

Regenerating or adding specifications

Specifications are converted from the authoritative CroatiaControlLtd/asterix XML definitions — never hand-written:

python tools/xml_to_spec.py asterix_cat062_1_18.xml \
    src/pyasteryx/spec/data/cat062/1.18.json

Development

python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
pytest
ruff check .

Non-goals

No GUI, no visualization, no ATM algorithms. pyasteryx focuses on decoding, parsing, streaming, exporting and interoperability.

Contributing

Every change lands through a pull request from a topic branch; main is always releasable. Branch naming, the local check loop and the release process are in CONTRIBUTING.md.

Citing

If you use pyasteryx in research, please cite it. GitHub reads CITATION.cff and offers a formatted citation under Cite this repository on the repository page.

Menjivar, E. (2026). pyasteryx (version 0.3.0).
https://github.com/franmenjivar/pyasteryx

Each release is archived on Zenodo with its own DOI, so you can cite the exact version you ran.

License

MIT — see LICENSE.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pyasteryx-0.3.0.tar.gz (75.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pyasteryx-0.3.0-py3-none-any.whl (71.5 kB view details)

Uploaded Python 3

File details

Details for the file pyasteryx-0.3.0.tar.gz.

File metadata

  • Download URL: pyasteryx-0.3.0.tar.gz
  • Upload date:
  • Size: 75.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for pyasteryx-0.3.0.tar.gz
Algorithm Hash digest
SHA256 fde021bc00b3863ac7596e5d1e8558349eed714eb76ceb811c4e07db7ddbf7a6
MD5 c56660a88467f67dd9537668903a8c70
BLAKE2b-256 5223c78a33ae641d78ca612b0437dce6fed0bd30db1978cb462641226f7bb625

See more details on using hashes here.

Provenance

The following attestation bundles were made for pyasteryx-0.3.0.tar.gz:

Publisher: ci.yml on franmenjivar/pyasteryx

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file pyasteryx-0.3.0-py3-none-any.whl.

File metadata

  • Download URL: pyasteryx-0.3.0-py3-none-any.whl
  • Upload date:
  • Size: 71.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for pyasteryx-0.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 cc0545fadda96c6f3567da722197477a14a08b7b2f17cfa892c13edf80f6a009
MD5 1c6e60efbaa9967ba9a252e8f52408bd
BLAKE2b-256 cc211d36d45908a64f68de0e9d4d4440a52be5559aaa0c35c31377eaf83c5266

See more details on using hashes here.

Provenance

The following attestation bundles were made for pyasteryx-0.3.0-py3-none-any.whl:

Publisher: ci.yml on franmenjivar/pyasteryx

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.3.0 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page