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

Methods
MethodEndpointReturns
client.fmu.export(simulation_id)POST /v1/simulations/{id}/export/fmuNew 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}/cancelJob 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}/downloadpath, 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/failed simulation 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

Status shape
KeyNotes
statusqueued | solving | completed | failed
chunks_done / chunks_totalPresent while solving
filenamePresent once completed — pass straight to download()
bytesArtifact size once completed
artifact_availableTrue only while the completed artifact is still downloadable (24 h window)
errorPresent 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-in thrustlab.sitl bridge; plot_mission.py renders the flight-report PNGs below and render_mission_video.py turns 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 a gz-sim world 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.

Mission track colored by electrical power, with the altitude profile

FMU outputs over the mission: flight profile, per-rotor speed, battery voltage and current, power and state of charge

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:

Loiter and exit close-up: the four rotors split as the mixer commands the exit turn

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:

Y6 coax stacks: per-stack rotor speed and the lower rotor's sub-50% thrust share

See also

  • Simulations — the source single_point run an export is built from
  • Async resources — the shared poll/wait contract
  • Error handlingConflictError, NotFoundError and the rest of the typed hierarchy