Async resources

Simulations, sweeps, and dynamic runs are async. Creating one returns immediately with a non-terminal status. Collect the result by polling, or by subscribing to webhook events.

Polling with the SDK

wait() polls the resource at a configurable interval until it is terminal.

"""Single-point simulation: auth -> submit -> wait() -> read the canonical result.

Run it:
    export THRUSTLAB_API_KEY=key_...        # never hard-code the key
    python examples/simulations/run_sync.py

The verification combo below (BadAss 2826-820Kv + 10.5x4.5 + Liperior
4S 5000 mAh @ 70% throttle, static) converges to ~9.07 N / ~8505 rpm. 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 single-point example")

# Resolve components by name (find() returns the single match or raises
# AmbiguousComponentError / NotFoundError). Or paste explicit IDs instead:
#   motor_id = "comp_motor_xxx"
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"
)

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,
        }
    ],
)
print(f"created {sim['id']}, status={sim['status']}")

# wait() polls until completed / failed / canceled (or a timeout sentinel).
result = client.simulations.wait(sim["id"], timeout=300)
print(f"final status: {result['status']}")

if result["status"] == "completed":
    # Canonical snake_case result (post-D-01): per-rotor group under its label
    # index ("1", "2", ...); aggregate roll-ups under "All".
    per_rotor = result["result"]["1"]
    aggregate = result["result"]["All"]
    print(f"per-rotor thrust: {per_rotor['thrust_n']:.2f} N")
    print(f"per-rotor rpm:    {per_rotor['rpm']:.0f}")
    print(f"total thrust:     {aggregate['total_thrust_n']:.2f} N")

Terminal states are completed, failed, and canceled (one l on the wire). The SDK adds one client-side terminal state, timed_out, when wait() exceeds its timeout= parameter.

Subscribing via webhooks

For long-running sweeps and production pipelines, subscribe to simulation.completed, simulation.failed, sweep.completed, and sweep.failed through a webhook endpoint. See webhooks and event types.

Run timestamps

Run timestamps
FieldSet when
created_atThe row was created.
dispatched_atThe run was handed to the worker queue.
started_atThe solver began work on it.
completed_atThe run reached a terminal state.

Queue wait is started_at - dispatched_at. Before started_at existed there was no way to measure it from the resource at all. For a dynamic run, dispatched_at equals created_at, because creating a dynamic run dispatches it. For a sweep or a single-point run submitted with launch_intent: "queue", dispatched_at is later than created_at, and is null until something dispatches it. Solver time is completed_at - started_at.

Dispatching parked runs

launch_intent on POST /v1/sweeps and POST /v1/simulations takes three values. "run" (the default) reserves compute units and dispatches immediately. "queue" reserves compute units and parks the run. "draft" stores it with no reservation and no dispatch.

A parked run stays queued with dispatched_at: null. Two endpoints dispatch parked runs.

POST /v1/simulations/run-queued dispatches every parked run the caller owns, across all projects. It takes no body.

POST /v1/simulations/run-selected dispatches a named subset. Body: {"simulation_ids": ["sim_...", "sweep_..."]}, with 1 to 1000 ids. Sweep ids are accepted here despite the field name.

Both endpoints group the batch by rotor count and return {"groups": [...], "compiles_needed": 0, "rotor_counts": [...]}.

An id that is not queued returns 400 invalid_status. An id already handed to a worker returns 400 already_dispatched. An id owned by someone else returns 404.

Dynamic runs have no parked state and no dispatch endpoint. Creating one dispatches it.

Cancelling a run

A queued run of any kind cancels immediately. The response comes back canceled and compute units are refunded in full.

A RUNNING sweep and a RUNNING dynamic run behave the same way. The response keeps status: "running" and sets cancel_requested_at. Poll until the status turns canceled.

For a dynamic run the worker stops at its next integration window. That is at most 20 seconds of MISSION time, not wall time. It then stores the partial result with run_meta.partial: true and run_meta.canceled_at_t_s (the mission time it stopped at), turns canceled, refunds, and frees the execution slot. The partial result is readable like any other result.

POST /v1/simulations/cancel-selected takes up to 1000 run ids of any kind and returns {"canceled": [...], "requested": [...], "skipped": [...]}. Running sweeps and running dynamic runs come back under requested; poll those. skipped entries carry a reason.

A running single-point simulation cannot be canceled. Those finish in seconds.

Dynamic run progress

A dynamic run carries progress on the resource and on every dynamic.updated SSE frame, shaped {fraction, sim_time_s, sim_time_total_s, wall_s, updated_at}.

fraction is 0 to 1 for a fixed-duration mission. For a run-to-depletion mission the total is not known in advance, so fraction is an estimate capped below 1, and can be null. sim_time_s is mission time integrated so far. sim_time_total_s is the mission's total duration, or null for run-to-depletion. wall_s is elapsed wall-clock seconds.

POST /v1/dynamic-simulations/estimate returns estimated_duration_s, which is SIMULATED mission time, not wall time. It is not a prediction of how long the run will take. A completed run reports its wall time as run_meta.wall_s.

Poll cadence: 2 to 5 seconds, backing off when nothing changes. A dynamic run to depletion can integrate for several minutes.