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
| Field | Set when |
|---|---|
created_at | The row was created. |
dispatched_at | The run was handed to the worker queue. |
started_at | The solver began work on it. |
completed_at | The 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.