GitHub

Quick start #

Load a log, then read frames and series with units attached.

Quick start
python
import mavpose

log = mavpose.load("flight.tlog")
log
# <FlightLog 'flight.tlog': mavlink, 38 message types, 2443.8 s>

alt = log.series("GLOBAL_POSITION_INT", "relative_alt")
alt.attrs
# {'unit': 'm', 'msg_type': 'GLOBAL_POSITION_INT'}

log.unknown_counts
# {'GPS_RAW_INT': {'epv': 6338}}

log.to_parquet("telemetry.parquet")
💡

Outputs above come from a real 40-minute ArduPilot flight log. Every frame shares one time_s axis (seconds from log start), scaled fields are in natural units, and "value not provided" markers are NaN.

Installation #

Python 3.10 or newer. The core has no AI dependencies; add extras only if you need them.

Install
bash
# Data layer only: 10 packages, no AI dependencies
pip install mavpose

# Optional extras
# MCP server for AI agents
pip install "mavpose[mcp]"
# plain-English plot assistant
pip install "mavpose[chat]"

Log formats #

load() picks the reader from the file extension. Every format gets the same guarantees.

FormatExtensionAutopilotMessage names
MAVLink telemetry log.tlogArduPilot, PX4, any MAVLink vehicleMAVLink names, e.g. GLOBAL_POSITION_INT
DataFlash binary / text log.bin, .logArduPilotDataFlash names, e.g. GPS, BAT
ULog.ulgPX4Topic plus instance, e.g. vehicle_gps_position_0

mavpose.load() #

The main entry point.

function
mavpose.load(path: str, *, convert_units: bool = True, filter_unknown: bool = True) → FlightLog
Read a flight log into a FlightLog. The format is detected from the extension.

Validates the file, parses every message, puts all message types on one time_s axis, converts units and replaces unknown markers. Damaged packets are skipped and counted in log.stats.

NameTypeDefaultDescription
path str Required A MAVLink .tlog, ArduPilot .bin/.log or PX4 .ulg file.
convert_units bool True Convert scaled fields to natural units (mm → m, degE7 → deg, mV → V…). Pass False to keep raw stored values, with raw units reported.
filter_unknown bool True Replace "value not provided" markers (e.g. current_battery = -1) with NaN.
Returns

FlightLog

Raises

FileNotFoundError — the file doesn't exist. FileValidationError — unsupported extension, empty, larger than 200 MB, or a symlink. ValueError — the file can't be parsed as its format.

class FlightLog #

A parsed flight log: one time-aligned DataFrame per message type, with units. Create one with mavpose.load().

method
messages() → List[str]
Sorted names of the message types (topics) in the log.

Also: len(log), "ATTITUDE" in log and for msg in log work as you'd expect.

method
fields(msg_type: str) → List[str]
Data fields of a message type, excluding time_s and msg_type.
Raises

KeyError — the message type or field doesn't exist. The message suggests close matches, e.g. No message type 'GLOBAL_POS'. Did you mean: GLOBAL_POSITION_INT?

method
frame(msg_type: str) → pd.DataFrame
All rows of a message type. Also available as log[msg_type].

Columns are time_s, msg_type and one column per field. Returns a copy, so it is safe to modify.

Raises

KeyError — the message type or field doesn't exist. The message suggests close matches, e.g. No message type 'GLOBAL_POS'. Did you mean: GLOBAL_POSITION_INT?

method
series(msg_type: str, field: str) → pd.Series
One field as a Series indexed by time_s, with its unit attached.

The unit is in series.attrs["unit"] (None if unknown) and the source in series.attrs["msg_type"]. Ready for any pandas or matplotlib workflow.

python
alt = log.series("GLOBAL_POSITION_INT", "relative_alt")
alt.plot(ylabel=f"altitude ({alt.attrs['unit']})")
Raises

KeyError — the message type or field doesn't exist. The message suggests close matches, e.g. No message type 'GLOBAL_POS'. Did you mean: GLOBAL_POSITION_INT?

method
unit(msg_type: str, field: str) → Optional[str]
Unit of a field after conversion, e.g. "m", or None if unknown or dimensionless.
python
log.unit("GLOBAL_POSITION_INT", "alt")            # 'm'
raw = mavpose.load("flight.tlog", convert_units=False)
raw.unit("GLOBAL_POSITION_INT", "alt")            # 'mm'
property
units
Every known unit: {msg_type: {field: unit}}.

Fields with no known unit are omitted.

property
unknown_counts
How many values were replaced by NaN, per message type and field.
python
log.unknown_counts
# {'GPS_RAW_INT': {'epv': 6338}}   this GPS never reports vertical accuracy
property
stats
Parse counters: messages, bad_data, errors, unknown_values.

bad_data counts corrupt packets that were skipped; errors counts reader exceptions recovered from. A warning is logged when either is non-zero. Parsing only gives up after 1,000 consecutive read errors.

python
log.stats
# {'messages': 163851, 'bad_data': 0, 'errors': 0, 'unknown_values': 6338}
property
duration_s
Seconds between the first and last sample, across all message types.

float; 0.0 for an empty log.

method
summary(msg_types: Optional[List[str]] = None) → Dict[str, dict]
Per-column dtype, unit, min/max and unknown count — the schema MAVPose gives to language models.
python
log.summary(["SYS_STATUS"])["SYS_STATUS"]["columns"]["current_battery"]
# {'dtype': 'float64', 'unit': 'A', 'min': 10.5, 'max': 10.5, 'unknown': 3}
Raises

KeyError — a requested message type doesn't exist.

method
to_parquet(path: str, msg_types: Optional[List[str]] = None) → Dict[str, dict]
Write message types (all by default) to one Parquet file.

Rows from different message types are told apart by the msg_type column.

Returns

The summary() of what was written.

Raises

KeyError — a requested message type doesn't exist.

property
path, format
The log's path, and its format: "mavlink", "dataflash" or "ulog".

Plain attributes: log.path (str) and log.format (str).

class LogExtractor #

The extractor load() is built on.

init
LogExtractor(log_path: str, convert_units: bool = True, filter_unknown: bool = True)
Lower-level extractor behind load(). Use it when you need a schema scan without loading rows.

Same options as load(). Does not validate the file; call validate_mavlink_file() first for untrusted input.

Raises

FileNotFoundError — the file doesn't exist.

method
schema_only() → Dict[str, dict]
Fast single pass that collects message metadata without storing rows.
Returns

{msg_type: {"count": int, "fields": {name: type}, "units": {name: unit}}}

method
extract_all() → Dict[str, pd.DataFrame]
Full extraction: one DataFrame per message type.
Returns

{msg_type: DataFrame} with columns time_s, msg_type and one per field. Also stored in .frames.

method
summary(msg_types: Optional[List[str]] = None) → Dict[str, dict]
Same shape as FlightLog.summary().

Runs extract_all() first if needed.

method
export_parquet(msg_types: List[str], output_path: str) → Dict[str, dict]
Write the given message types to one Parquet file.

Types not present in the log are skipped with a warning.

Returns

The summary() of what was written.

property
frames, units, raw_units, unknown_counts, stats
Results of the last extraction.

raw_units gives the units as stored in the log, before conversion; units gives them after.

Validation #

Input checks, also run by load().

function
mavpose.validate_mavlink_file(path: str) → None
Check a file before parsing it.

Accepts .tlog, .bin, .log and .ulg. Rejects symlinks, empty files and files over 200 MB. load() calls it for you.

Raises

FileNotFoundError — the file doesn't exist. mavpose.FileValidationError (a ValueError) — any other failed check.

MCP server — mavpose-mcp #

A Model Context Protocol server, so any MCP-capable assistant or agent framework can read your logs with MAVPose's guarantees. Needs [mcp].

Run
bash
mavpose-mcp --root ~/flight-logs
# Pass --root several times for several folders. Without it, the server
# uses $MAVPOSE_MCP_ROOTS (":"-separated; ";" on Windows), else the current folder.
Client configuration (file and key names vary by client)
json
{
  "mcpServers": {
    "mavpose": {
      "command": "mavpose-mcp",
      "args": ["--root", "/home/you/flight-logs"]
    }
  }
}
ToolReturnsWrites?
list_logsLog files in the allowed folders, with format and sizeno
summarize_flightDuration; altitude, speed, battery and GPS ranges (each with source field and unit); parse stats; unknown counts; vehicle warningsno
list_messagesMessage types with row counts, field counts and time rangeno
describe_messageOne message type's fields: dtype, unit, min, max, unknown countno
get_seriesFields over a time window (start_s/end_s) on time_s, with units. At most max_points samples (500 default, 5,000 max); min, max and mean cover every sampleno
get_log_messagesThe vehicle's text log (STATUSTEXT, MSG, PX4 log messages), filterable by severityno
export_parquetWrites message types to a .parquet file inside an allowed folderyes, never overwrites unless overwrite is true
🔒

The server reads, and export_parquet writes, only inside the --root folders. Paths that resolve outside them (including via ..) and symlinked logs are refused. No tool executes code, and every tool except export_parquet is annotated read-only. Parsed logs are cached (four at a time) and re-read if the file changes.

Plot assistant — mavpose #

Turns a plain-English request into a plot, with any AI provider. Needs [chat].

Usage
bash
pip install "mavpose[chat]"
export ANTHROPIC_API_KEY=...          # or OPENAI_API_KEY, GEMINI_API_KEY, … (or a .env file)
mavpose flight.tlog --model anthropic:<model> -p "Plot altitude and battery"
mavpose flight.ulg --model ollama:llama3.1:8b   # local; interactive mode
mavpose next_flight.bin --spec plot.json         # redraw, no model
FlagShortTypeDefaultDescription
log_file—pathRequiredMAVLink .tlog, ArduPilot .bin/.log, or PX4 .ulg.
--prompt-pstrNonePlot request. Omit for interactive mode.
--model-mstrNoneprovider:model. Default: MAVPOSE_MODEL, or the provider whose key is set.
--spec—pathNoneRedraw a saved plot.json on this log. No model or key needed.
--out-dir-opathlog's folderFolder for the plot, its spec or script, and telemetry.parquet; created if needed.
--format-fstrpngpng, svg or pdf.
--dpi—int150Resolution (400 by default with --code); ignored for SVG.
--code—flagFalseHave the model write a Python script instead of a plot spec.
--retries-rint3Attempts per request when the spec or script fails.
--verbose-vflagFalseEnable DEBUG logging.
ProviderKey variableNotes
openaiOPENAI_API_KEY
anthropicANTHROPIC_API_KEYnative client
geminiGEMINI_API_KEY / GOOGLE_API_KEY
openrouterOPENROUTER_API_KEYmodel optional (default z-ai/glm-5.1, or OPENROUTER_MODEL)
groq, deepseek, mistral, togetherGROQ_API_KEY, DEEPSEEK_API_KEY, MISTRAL_API_KEY, TOGETHER_API_KEY
ollama, lmstudiononelocal, localhost:11434 / localhost:1234
openai-compatibleMAVPOSE_API_KEY (optional)needs MAVPOSE_BASE_URL
⚙️

Choose with --model provider:model or MAVPOSE_MODEL. Without either, OpenRouter is used if OPENROUTER_API_KEY is set (as in 0.2), otherwise the one provider whose key is set. MAVPOSE_BASE_URL and MAVPOSE_API_KEY override the endpoint and key. Settings can live in a .env file in the folder you run mavpose from.

Plot spec (saved as plot.json)
json
{
  "title": "Climb and battery",
  "panels": [
    {"series": [{"msg_type": "GLOBAL_POSITION_INT", "field": "relative_alt"}]},
    {"series": [{"msg_type": "SYS_STATUS", "field": "voltage_battery"},
                {"msg_type": "SYS_STATUS", "field": "current_battery", "axis": "right"}]}
  ]
}
📋

The model sees the log's message types and the summary() schema, never the raw log, and replies with a plot spec: up to 6 panels sharing the time axis, line / scatter / step, a right-hand axis, colours, x_field for tracks, and a start_s/end_s window. MAVPose validates it against the log, sends errors back to the model, and draws it with units from the log. mavpose.chat.render_spec(spec, frames, units, path) draws specs from Python; mavpose.chat.PlotCreator is the class behind the command; in code mode its run_script() returns a RunResult (success, plot_path, code, error, attempts). In interactive mode the model sees your last three requests.

🛡️

By default no model-written code runs. With --code, the generated script runs in a separate process with no API keys in its environment, under an audit hook that blocks process spawning, network access and writes outside the output folder, and with CPU, memory and time limits. This is defence in depth, not a security boundary: for untrusted logs or prompts in code mode, run MAVPose in a container too.

API stability #

MAVPose follows Semantic Versioning. These names are covered.

NameWhat it is
mavpose.load()Read a log into a FlightLog
mavpose.FlightLogAll methods and properties documented above
mavpose.LogExtractorAll methods and properties documented above
mavpose.validate_mavlink_file(), mavpose.FileValidationErrorInput validation
mavpose.__version__Installed version
The mavpose command and the plot spec formatPlot assistant CLI (needs [chat]); saved plot.json files keep working
The mavpose-mcp command and its toolsTool names, arguments and result fields (needs [mcp])
📐

The data guarantees are public API too: the shared time_s axis, unit conversion, unknown-marker filtering and message naming. Changing any of them counts as a breaking change.

🧭

MAVPose is pre-1.0. Minor releases (0.2 → 0.3) may break things and list each change under "Breaking" in the changelog; patch releases never do. Not public: names starting with _, mavpose.ulog_reader, mavpose.mcp_server, mavpose.chat.* except PlotCreator, and files under mavpose/data/. Full policy: docs/api-stability.md.