FMU export resource
Accessor: client.fmu
Export a completed single-point simulation as an FMI 3.0 Co-Simulation FMU —
a Modelica-standard .fmu holding a generic map interpolator plus operating-
point tables sampled for that exact powertrain. It carries no solver, no
component specs and no calibration data. Requires the fmi_export
capability (Pro).
Export is asynchronous, same pattern as simulations and sweeps: queue the
job, poll (or wait()) until it reaches a terminal state, then download the
artifact — available for 24 hours after completion.
Methods
| Method | Endpoint | Returns |
|---|---|---|
client.fmu.export(simulation_id) | POST /v1/simulations/{id}/export/fmu | New job in queued state |
client.fmu.status(job_id) | GET /v1/simulations/export/fmu/{job_id} | Current job status |
client.fmu.cancel(job_id) | POST /v1/simulations/export/fmu/{job_id}/cancel | Job status after the cancel request |
client.fmu.wait(job_id, on_progress=None, timeout=None) | (polls status) | Final job status when terminal |
client.fmu.download(job_id, path) | GET /v1/simulations/export/fmu/{job_id}/download | path, after writing the .fmu bytes to it |
GET /v1/simulations/export/fmu/{job_id}/stream (SSE) is not wrapped — no
polling/wait use case for it in the SDK; it's what the web app's own export
progress tab consumes directly.
Export source requirements
The source simulation must be:
- Completed — a
queued/running/failedsimulation 422s. single_point— export one operating configuration at a time, not a sweep.- Single-pack battery — no multi-pack battery topology.
- Heterogeneous rotor groups (e.g. a quad with two different propellers) ARE supported — schema 3 samples one set of component surfaces per distinct hardware group. So are coaxial pairs: each lane exports its own interference-baked aero table with the partner's speed ratio as an extra axis, so the FMU reproduces the stack even when the two rotors desync. Expect a coax export to sample substantially longer than a solo one.
One export job runs per account at a time; a second export() call while one
is in flight raises ConflictError (export_in_progress).
Run it: export, wait with progress, download
"""FMU export: run a simulation -> export -> wait (with progress) -> download.
Run it:
export THRUSTLAB_API_KEY=key_... # never hard-code the key
python examples/fmu/export_and_download.py
Requires the `fmi_export` capability (Pro tier) on the calling account.
Exports the verification combo (BadAss 2826-820Kv + 10.5x4.5 + Liperior
4S 5000 mAh @ 70% throttle, static) as an FMI 3.0 Co-Simulation FMU. Swap in
your own component IDs, or resolve them by name with
client.components.find(...).
"""
from thrustlab import Client
client = Client() # reads $THRUSTLAB_API_KEY from the environment
project = client.projects.create(name="sdk fmu export example")
motor = client.components.find(name="BadAss 2826-820Kv")
prop = client.components.find(name="10.5x4.5")
battery = client.components.find(
name="Liperior 5000mAh 4S 35C 14.8V Lipo Battery With XT90 Plug"
)
# The FMU export source must be a COMPLETED single-point simulation: no
# multi-pack battery topology, no coaxial stack (heterogeneous rotor groups
# ARE supported).
sim = client.simulations.create(
project_id=project["id"],
battery_component_id=battery["id"],
airspeed_m_s=0.0,
density_kg_m3=1.225,
battery_charge_pct=100,
rotor_groups=[
{
"label": "main",
"count": 4,
"motor_component_id": motor["id"],
"propeller_component_id": prop["id"],
"throttle_pct": 70,
}
],
)
sim = client.simulations.wait(sim["id"], timeout=300)
if sim["status"] != "completed":
raise SystemExit(f"source simulation did not complete: {sim['status']}")
print(f"source simulation {sim['id']} completed")
# export() queues the job and returns immediately (202 + job id).
job = client.fmu.export(sim["id"])
print(f"export job {job['id']} queued")
def on_progress(status: dict) -> None:
# `chunks_done`/`chunks_total` are always present while solving; treat any
# other key (`progress`, `eta_s`, `phase`, ...) as optional — the status
# contract is additive across releases.
chunks = ""
if status.get("chunks_total"):
chunks = f" ({status.get('chunks_done', 0)}/{status['chunks_total']})"
print(f" status: {status['status']}{chunks}")
# No default timeout on fmu.wait() — a lane-table/aero-table build can run
# well past the 600 s used elsewhere in the SDK. Pass timeout=... to cap it.
result = client.fmu.wait(job["id"], on_progress=on_progress, poll_interval=2.0)
if result["status"] != "completed":
# wait() never raises on a failed job — check status() and error yourself,
# same as every other resource's wait().
error = result.get("error") or {}
raise SystemExit(
f"export {job['id']} did not complete: {result['status']} "
f"({error.get('error_code')}: {error.get('message')})"
)
dest = f"{result.get('filename', 'powertrain.fmu')}"
out_path = client.fmu.download(job["id"], dest)
print(f"downloaded {out_path} ({result.get('bytes', '?')} bytes)")
job = client.fmu.export(sim["id"])
print(f"export job {job['id']} queued")
def on_progress(status):
print(f" status: {status['status']}")
result = client.fmu.wait(job["id"], on_progress=on_progress)
if result["status"] == "completed":
client.fmu.download(job["id"], result["filename"])
on_progress, if given, is called with every status response as wait()
polls, including non-terminal ones — a natural hook for a CLI progress line or
a UI callback. The status object may carry additional fields beyond the ones
below (e.g. a coarse progress, eta_s, or phase) as the export worker's
status contract grows; treat any key not documented here as optional and
don't assume it's present.
Status shape
| Key | Notes |
|---|---|
status | queued | solving | completed | failed |
chunks_done / chunks_total | Present while solving |
filename | Present once completed — pass straight to download() |
bytes | Artifact size once completed |
artifact_available | True only while the completed artifact is still downloadable (24 h window) |
error | Present when failed — {"error_code": ..., "message": ...} |
wait() does not raise on a failed job
Unlike a plain HTTP error, a failed export job is not an exception —
wait() returns the job's dict with status: "failed", exactly like
client.simulations.wait() / client.sweeps.wait() /
client.dynamic_simulations.wait(). Check result["status"] yourself, and
read result["error"] for the reason:
result = client.fmu.wait(job["id"])
if result["status"] != "completed":
error = result.get("error") or {}
print(f"export failed: {error.get('error_code')}: {error.get('message')}")
client.fmu.wait() also differs from the other resources' wait() in one
way: timeout defaults to None (wait indefinitely) rather than 600.
An export's table-build cost isn't bounded the way a single steady-state
point is. Pass an explicit timeout to cap it — on expiry the returned dict
has status: "timed_out" (an SDK-side sentinel; the job keeps running
server-side).
Cancel an in-flight export
client.fmu.cancel(job["id"])
Cooperative — the worker stops at the next chunk boundary. Poll or wait()
to observe the job settle to status: "failed" with
error.error_code == "export_cancelled". A no-op on an already-terminal job.
Download the artifact
client.fmu.download(job["id"], "powertrain.fmu")
Writes the .fmu bytes to path and returns it. Available for 24 hours
after status()["artifact_available"] turns True; past that window this
raises NotFoundError (export_expired) and the export must be run again.
ArduPilot SITL and Gazebo
Five runnable examples ship alongside export_and_download.py under
examples/fmu/ — each README carries its own setup:
ardupilot_sitl/— a quad flies a multi-leg AUTO mission through the exported FMU via the SDK's built-inthrustlab.sitlbridge;plot_mission.pyrenders the flight-report PNGs below andrender_mission_video.pyturns the same recording into an animated replay.ardupilot_sitl_y6/— the coaxial version: a Y6 whose six rotors are three contra-rotating pairs from a coax export, plus the per-stack upper/lower split figure.ardupilot_sitl_tilttri/— a tilt-rotor tricopter QuadPlane flies the full VTOL transition: vertical takeoff, tilt to a fixed-wing cruise circuit, and back. Needs a plane SITL build and a 3-rotor export.endurance/— no simulator at all: fmpy drives the FMU directly to answer hover endurance vs payload, battery sag included.gazebo/— the FMU as propulsion + battery physics in agz-simworld via the ThrustLab gz-sim plugin.
The figures below are the SITL example's actual output: an exported quad powertrain (four 3207 motors on 10×4.7 props, 6S) flying a multi-leg AUTO mission — takeoff, 13 m/s dash, climb to 32 m, two loiter turns, a fast descent leg and RTL — with the FMU as the vehicle physics.


Battery voltage and current feed back into the firmware's own battery monitor over the JSON protocol; per-rotor speed is FMU state, integrated from each rotor's torque imbalance, so spin-up lag and the mixer's per-rotor splits are visible:

A coax export carries the stack coupling per rotor. From the Y6 example's flight: each lower rotor spins faster than its upper partner yet holds less than half the stack's thrust for the entire mission, because it flies in the upper's induced flow — per-rotor truth the autopilot never sees:

See also
- Simulations — the source
single_pointrun an export is built from - Async resources — the shared poll/wait contract
- Error handling —
ConflictError,NotFoundErrorand the rest of the typed hierarchy